@takagaki/cortex-decisions-viewer 0.4.57 → 0.4.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/build.js CHANGED
@@ -157,8 +157,16 @@ async function readFleetSources(repoRoot) {
157
157
  label: String(s.label ?? ""),
158
158
  url: s.url != null ? String(s.url) : undefined,
159
159
  lastSync: s.lastSync != null ? String(s.lastSync) : undefined,
160
- matchKeys: Array.isArray(s.matchKeys)
161
- ? s.matchKeys.map((k) => String(k)).filter(Boolean)
160
+ // 会議名に入れる合図。**古い fleet-status.json の matchKeys は引き継がない**
161
+ // (client名と廃止した meetingNamePatterns の寄せ集めで、いま照合には使われていない。
162
+ // 出すと「この語を会議名に入れれば拾われる」と読めてしまう)。
163
+ meetingKey: s.meetingKey != null ? String(s.meetingKey) : undefined,
164
+ // 既存の定例を、いまの名前のまま拾うために登録した別名。設定ファイルにしか
165
+ // 無いと「なぜこの会議だけ入るのか」が画面から分からない
166
+ // **String() を先に噛ませない。** `String(null)` は "null" という文字列になり
167
+ // `.filter(Boolean)` を通り抜ける(画面に「null」という会議名が出る)
168
+ meetingAliases: Array.isArray(s.meetingAliases)
169
+ ? s.meetingAliases.filter((a) => typeof a === "string" && a !== "")
162
170
  : undefined,
163
171
  driveSync: typeof s.driveSync === "boolean" ? s.driveSync : undefined,
164
172
  enabled: typeof s.enabled === "boolean" ? s.enabled : undefined,
package/dist/render.js CHANGED
@@ -742,6 +742,9 @@ a:hover { text-decoration: underline; }
742
742
  .datasources-head { font-size: 18px; font-weight: 700; color: var(--text); margin: 0 0 4px; }
743
743
  .datasources-intro { color: var(--muted); font-size: 13.5px; line-height: 1.8; margin: 0 0 6px; }
744
744
  .datasources-asof { color: var(--muted); font-size: 12.5px; line-height: 1.7; margin: 0 0 14px; }
745
+ /* 最新を取得できなかったとき。古い値を現在値と読ませないよう、通常の注記より目立たせる */
746
+ .datasources-asof.is-stale { color: var(--warn, #a15c00); }
747
+ .conn-map-asof { color: var(--muted); font-size: 12px; margin: 6px 0 0; text-align: right; }
745
748
  .ds-sub { font-size: 13px; font-weight: 700; color: var(--muted); margin: 20px 0 8px; }
746
749
  .ds-list { display: flex; flex-direction: column; gap: 10px; }
747
750
  .ds-card {
@@ -795,6 +798,12 @@ a.ds-name:hover { text-decoration: underline; }
795
798
  .cm-sync { font-size: 10.5px; fill: var(--muted); font-variant-numeric: tabular-nums; }
796
799
  .cm-node.cm-off .cm-sync { fill: #9ca3af; }
797
800
  .cm-badge { font-size: 13px; }
801
+ /* 「入ってこない」ときに自分で確かめる口。一覧の直前に置く */
802
+ .ds-syncnow { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin: 0 0 .6rem; }
803
+ .ds-syncnow-label { font-size: .82rem; color: var(--muted); }
804
+ /* 自分で繋げばよいことを、手順より先に伝える。埋もれると管理者に依頼しに行ってしまう */
805
+ .sec-preferred { margin: .4rem 0 .2rem; padding: .5rem .7rem; font-size: .84rem; line-height: 1.6;
806
+ border-left: 3px solid var(--accent); background: var(--accent-weak); border-radius: 0 6px 6px 0; }
798
807
  /* 追加した直後にその場で見せる行。正式な一覧(スナップショット由来)に載るまでの繋ぎ */
799
808
  .ds-pending { margin-top: .5rem; display: flex; flex-direction: column; gap: .25rem; }
800
809
  .ds-pending-item { display: flex; align-items: center; gap: .5rem; font-size: .85rem;
@@ -819,6 +828,11 @@ a.ds-name:hover { text-decoration: underline; }
819
828
  .ds-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; flex: 0 0 auto; }
820
829
  .badge.ds-gold { background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; }
821
830
  .badge.ds-excluded { background: #eceef1; color: #6b7280; }
831
+ /* 保存は済んでいるが、接続状況の再計算(1〜2分)がまだのもの。**確定と同じ見た目にしない**
832
+ ——リロードで消えたときに「戻された」と読まれるため、点線で区別して (反映中) を添える。
833
+ **幅と色まで指定する。** border-style だけだと、border を持たない .ds-excluded 側は
834
+ 初期値の medium(≈3px)/currentColor に解決され、押した向きによって太さ3倍・色が別物になる */
835
+ .badge.is-pending { border: 1px dashed currentColor; opacity: 0.85; }
822
836
  .badge.ds-undeclared { background: #fff4e5; color: #9a6700; }
823
837
  .ds-toggle { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
824
838
  .ds-toggle-btn { font-size: 12px; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); background: #fff; color: var(--text); cursor: pointer; }
@@ -1462,15 +1476,85 @@ const CLIENT_JS = `
1462
1476
  if (url) return el("a", { class: "ds-name", href: url, target: "_blank", rel: "noopener", text: text });
1463
1477
  return el("span", { class: "ds-name", text: text });
1464
1478
  }
1465
- function renderDataSources() {
1466
- var internals = SITE.internalSources || [];
1467
- var sources = SITE.externalSources || [];
1468
- var pipes = SITE.pipelines || [];
1479
+ /**
1480
+ * 「いま同期する」。中央の取り込み(Apps Script)を即時に起こす。
1481
+ *
1482
+ * **押しても即座に画面は変わらない。** 取り込み コミット → 接続状況の再生成 →
1483
+ * ビューアの再ビルド、と数分かかる。それを言わずにボタンだけ置くと、
1484
+ * 「押したのに何も起きない」と読まれる(設定の追加でまさにそうなった)。
1485
+ */
1486
+ function syncNowRow() {
1487
+ var row = el("div", { class: "ds-syncnow" });
1488
+ if (!SITE.intake) return row;
1489
+ var msg = el("span", { class: "ds-toggle-msg" });
1490
+ var mk = function (kind, label) {
1491
+ var btn = el("button", { class: "ds-toggle-btn", type: "button", text: label });
1492
+ btn.addEventListener("click", async function () {
1493
+ btn.disabled = true;
1494
+ msg.className = "ds-toggle-msg";
1495
+ msg.textContent = "実行中…";
1496
+ var intake = SITE.intake;
1497
+ var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
1498
+ try {
1499
+ var res = await fetch(pfx + "op=sync&t=" + encodeURIComponent(intake.token), {
1500
+ method: "POST",
1501
+ headers: { "content-type": "application/json" },
1502
+ body: JSON.stringify({ kind: kind }),
1503
+ });
1504
+ var j = await res.json().catch(function () { return {}; });
1505
+ // **起こせなかったときも「失敗」と言わない。** 定期実行が拾うので、
1506
+ // 押した人にできることは無い。何が起きるかだけを伝える
1507
+ msg.className = "ds-toggle-msg" + (res.ok && j.started ? " is-ok" : "");
1508
+ msg.textContent = (res.ok && j.started ? "✓ " : "") + (j.message || "開始できませんでした。");
1509
+ } catch (e) {
1510
+ msg.className = "ds-toggle-msg";
1511
+ msg.textContent = "通信に失敗しました。定期実行(30分ごと)で取り込まれます。";
1512
+ }
1513
+ btn.disabled = false;
1514
+ });
1515
+ return btn;
1516
+ };
1517
+ row.appendChild(el("span", { class: "ds-syncnow-label", text: "いま同期する:" }));
1518
+ row.appendChild(mk("materials", "資料"));
1519
+ row.appendChild(mk("transcripts", "文字起こし"));
1520
+ row.appendChild(msg);
1521
+ return row;
1522
+ }
1523
+
1524
+ /**
1525
+ * 接続状況のライブ値(op=state の応答)。取得前・取得失敗時は null。
1526
+ *
1527
+ * **静的ビルド(SITE)は捨てない。** ビルド時に焼かれた値で先に描き切り、取れたら差し替える。
1528
+ * こうすると (1) いま即座に出ている操作タブに読み込み待ちが入らない (2) 失敗しても
1529
+ * 既に描き終わっているので空表示にならない。詳細表示の本文(loadBody)と同じ考え方。
1530
+ */
1531
+ var LIVE_FLEET = null;
1532
+ var LIVE_FLEET_FAILED = false;
1533
+ /**
1534
+ * 接続状況の値を引く。ライブがあればライブ、無ければ静的ビルドの値。
1535
+ * SITE 側はキー名が fleetGeneratedAt だけ異なる(他は fleet-status.json と同名)。
1536
+ */
1537
+ function fleetVal(key) {
1538
+ // **ライブが取れたら、そちらだけを見る(キー単位で静的へ落ちない)。**
1539
+ // キーごとにフォールバックすると、たとえば externalSources はライブ・secretSources は
1540
+ // 前日のビルド、という**2つの時点が混ざった画面**になる。上に出す「◯◯時点」は1つしか
1541
+ // 無いので、その注記が嘘になる——「変えたはずなのに違う値が出ている」という、
1542
+ // この節がまさに警告している誤読を作ることになる。
1543
+ // 欠けているキーは「無い」として扱い、既存の空配列ガードに任せる。
1544
+ if (LIVE_FLEET) return LIVE_FLEET[key];
1545
+ return SITE[key === "generatedAt" ? "fleetGeneratedAt" : key];
1546
+ }
1547
+
1548
+ /** 組み立てだけを行う(設置は呼び出し側)。ライブ差し替えで同じ節を作り直すため分けている */
1549
+ function buildDataSources() {
1550
+ var internals = fleetVal("internalSources") || [];
1551
+ var sources = fleetVal("externalSources") || [];
1552
+ var pipes = fleetVal("pipelines") || [];
1469
1553
  // 能力→ツールの宣言(例: チャット: "none")。"none" の能力は「未使用」のグレー行で存在だけ見せる
1470
- var tools = SITE.tools || {};
1554
+ var tools = fleetVal("tools") || {};
1471
1555
  var chatOff = tools["チャット"] === "none";
1472
1556
  var devOff = tools["開発"] === "none";
1473
- if (!internals.length && !sources.length && !pipes.length && !chatOff && !devOff) return; // すべて無ければセクションごと出さない
1557
+ if (!internals.length && !sources.length && !pipes.length && !chatOff && !devOff) return null; // すべて無ければセクションごと出さない
1474
1558
 
1475
1559
  var sec = el("section", { class: "datasources" });
1476
1560
  sec.appendChild(el("h2", { class: "datasources-head", text: "データソースと自動化" }));
@@ -1478,14 +1562,33 @@ const CLIENT_JS = `
1478
1562
  // **いつ時点の情報かを必ず出す。** この節は夜間バッチが作るスナップショットを読んでいるので、
1479
1563
  // 設定を変えても次の生成までは古い値のまま。時刻が無いと「変えたのに反映されない」ではなく
1480
1564
  // 「変えたはずなのに違う値が出ている」と読まれてしまう(実際に設定変更直後にそうなった)。
1481
- var asOf = SITE.fleetGeneratedAt ? dsFmtTime(SITE.fleetGeneratedAt) : "";
1565
+ //
1566
+ // **取得できたかで文言を変える。** 静的ビルドの値をそのまま「現在値」として見せると、
1567
+ // まさにここが警告している誤読を自分で起こす。取りに行って失敗したことは隠さない。
1568
+ var asOf = fleetVal("generatedAt") ? dsFmtTime(fleetVal("generatedAt")) : "";
1482
1569
  if (asOf) {
1483
- sec.appendChild(el("p", { class: "datasources-asof",
1484
- text: "接続状況は " + asOf + " 時点の自動チェック結果です。設定を変えた場合、ここに反映されるのは次回の実行後になります。" }));
1570
+ var asOfText = LIVE_FLEET_FAILED
1571
+ ? "最新の接続状況を取得できませんでした。表示しているのは " + asOf + " 時点の情報です。"
1572
+ : "接続状況は " + asOf + " 時点の自動チェック結果です。設定を変えた場合、ここに反映されるのは次回の実行後になります。";
1573
+ sec.appendChild(el("p", {
1574
+ class: "datasources-asof" + (LIVE_FLEET_FAILED ? " is-stale" : ""),
1575
+ text: asOfText,
1576
+ }));
1485
1577
  }
1486
1578
 
1487
1579
  if (internals.length) {
1488
1580
  sec.appendChild(el("div", { class: "ds-sub", text: "リポジトリ内の情報源(自動同期)" }));
1581
+ // **「いま同期する」ボタンは出さない。** 受け口(Apps Script の Webアプリ)を
1582
+ // 公開できないため、押しても届かない。
1583
+ //
1584
+ // Workspace のポリシーで**匿名アクセス可能なWebアプリを公開できない**
1585
+ // (デプロイ設定の「アクセスできるユーザー」に「全員」が出ず、「Classmethod.jp 内の全員」まで)。
1586
+ // Lambda は Google アカウントを持たないのでドメイン制限を通れない。
1587
+ //
1588
+ // 代わりに**待ち時間そのものを縮めた**(トリガー30分→15分・設定キャッシュ30分→5分で
1589
+ // 最悪60分→20分)。押す口が要るなら、bot の資格情報で Lambda が認証する経路を
1590
+ // 用意してからここを戻す(syncNowRow と Lambda の op=sync は残してある)。
1591
+ // sec.appendChild(syncNowRow());
1489
1592
  var ilist = el("div", { class: "ds-list" });
1490
1593
  internals.forEach(function (s) {
1491
1594
  // ラベルの下に補足行(会議の照合キー・Drive未設定の注記)を重ねられるよう縦組みにする
@@ -1495,30 +1598,36 @@ const CLIENT_JS = `
1495
1598
  // (Router.gs:「フィルタは持たない…招待が同意のゲート」)。enabled はルーティングの
1496
1599
  // 可否であって取り込みの可否ではないので、OFFにすると**Botは録り続けるのに
1497
1600
  // 文字起こしがINBOXに溜まる**という、招待しないより悪い状態を作れてしまう。
1498
- // **案件キーを先頭に足す。** 照合は「案件キー 顧客名 meetingNamePatterns」の
1499
- // 3段で行われるが、fleet-status が持つのは後ろ2つだけ。第1段は艦隊レジストリの
1500
- // キーで、投入設定から取れる。運用規約「会議名に案件キーを含める」の本体なので、
1501
- // これが抜けていると一番使われている目印が画面に出ない。
1502
- var keys = [];
1601
+ // **合図(会議名に入れる目印)を出す。** 照合は「会議名に [合図] / 【合図】
1602
+ // 入っているか」だけ(Router.gs)。合図は案件リポの meetingKey、未宣言なら艦隊
1603
+ // レジストリのキーで、後者は案件リポも fleet-status も知らない——投入設定からしか取れない。
1604
+ //
1605
+ // **括弧を付けた形で出す。** 語だけ見せると 【KC定例】 のように混ぜて書かれ、
1606
+ // どこも一致せず未仕分けへ落ちる(実際に起きた)。そのままコピーできる形にする。
1607
+ var signal = null;
1503
1608
  // **自動取り込みが動いているときだけ出す。** Teams等で手動運用している案件に
1504
1609
  // 会議名の条件を出すと、自動で拾われると誤解させる(実際には拾われない)。
1505
1610
  if (s.kind === "会議" && s.ingestState === "on") {
1506
- // 案件キーは艦隊レジストリのもので、案件リポ自身は知らない(fleet-status にも無い)。
1507
- // ビルド時に注入される投入設定からしか取れないため、設定UIが未配線の案件では出ない。
1508
- if (SITE.intake && SITE.intake.projectKey) keys.push(SITE.intake.projectKey);
1509
- (s.matchKeys || []).forEach(function (k) {
1510
- // 照合は大小文字を区別しないので、違いだけの重複は出さない(「cortex / Cortex」は雑音)
1511
- var dup = keys.some(function (x) { return x.toLowerCase() === String(k).toLowerCase(); });
1512
- if (!dup) keys.push(k);
1513
- });
1611
+ signal = s.meetingKey || (SITE.intake && SITE.intake.projectKey) || null;
1612
+ }
1613
+ var aliases = (s.kind === "会議" && s.ingestState === "on" && s.meetingAliases) || [];
1614
+ if (signal) {
1615
+ col.appendChild(el("span", {
1616
+ class: "ds-keys",
1617
+ text: "会議名に入れる目印: 【" + signal + "】 または [" + signal + "]",
1618
+ title: "会議名のどこかにこの括弧付きの目印が入っていると、この案件に振り分けられます(大小文字は問いません)。括弧の中に他の文字を混ぜると一致しません。",
1619
+ }));
1514
1620
  }
1515
- if (keys.length) {
1516
- var shown = keys.slice(0, 6).join(" / ");
1517
- if (keys.length > 6) shown += " 他" + (keys.length - 6) + "件";
1621
+ if (aliases.length) {
1622
+ // **目印が無くても入る会議。** 既に走っていて改名を頼みにくい定例を、
1623
+ // いまの名前のまま登録したもの。出さないと「なぜこれだけ入るのか」
1624
+ // 「新しい定例をどこに足すのか」が画面から分からない
1625
+ var shown = aliases.slice(0, 5).join(" / ");
1626
+ if (aliases.length > 5) shown += " 他" + (aliases.length - 5) + "件";
1518
1627
  col.appendChild(el("span", {
1519
1628
  class: "ds-keys",
1520
- text: "取り込み対象の会議名: " + shown,
1521
- title: "会議名にこれらの語が含まれていると、この案件に振り分けられます",
1629
+ text: "目印なしで取り込む会議: " + shown,
1630
+ title: "この名前の会議は、目印が無くてもこの案件に振り分けられます(大小文字・前後の空白は問いません。ファイル名に使えない記号(コロン・スラッシュ等)は同じものとして扱います)。既に走っている定例を改名せずに取り込むための登録です。",
1522
1631
  }));
1523
1632
  }
1524
1633
  if (s.driveSync === false) {
@@ -1651,7 +1760,11 @@ const CLIENT_JS = `
1651
1760
  sec.appendChild(plist);
1652
1761
  }
1653
1762
 
1654
- app.appendChild(sec);
1763
+ return sec;
1764
+ }
1765
+ function renderDataSources() {
1766
+ var sec = buildDataSources();
1767
+ if (sec) app.appendChild(sec);
1655
1768
  }
1656
1769
  // 未使用能力のプレースホルダ行(アダプターとして存在するが、この案件では設定されていないもの)
1657
1770
  function inactiveSourceRow(icon, typeLabel, anchorId) {
@@ -1679,8 +1792,14 @@ const CLIENT_JS = `
1679
1792
  // **共通部品(stateToggle)に載せる。** 以前はここだけ変更者名をダイアログで聞いており、
1680
1793
  // 同じカードの中で2つの流儀が並んでいた。送信・エラー表示・向きの反転も個別実装で、
1681
1794
  // 片方だけ直したときにずれる状態だった。
1795
+ // **控えはボタンにも効かせる。** バッジだけに効かせると、リロード後に同じカードの中で
1796
+ // バッジが「対象です」・ボタンが「始めますか?」と逆を主張する。しかもその状態で
1797
+ // ボタンの表示どおりに押すと、サーバは既に同じ値なので changed:false を返し、
1798
+ // ボタンが永久に反転しない——**15分(TTL切れ)まで止められなくなる**。
1799
+ // stateToggle の doc が要件として書いている「戻す操作を必ず残す」を破る。
1800
+ var pendingNow = pendingGoldFor(s);
1682
1801
  return stateToggle({
1683
- on: s.gold === true,
1802
+ on: pendingNow ? pendingNow.value : s.gold === true,
1684
1803
  labelOn: "Gold昇格を止める",
1685
1804
  labelOff: "Gold昇格を始める",
1686
1805
  confirmOn: [
@@ -1696,6 +1815,23 @@ const CLIENT_JS = `
1696
1815
  build: function (next) {
1697
1816
  return { op: "set-channel-gold", ref: s.ref, value: next };
1698
1817
  },
1818
+ // 保存できた直後に、この行のバッジだけを「反映中」に差し替える。
1819
+ // 一覧そのものは触らない(結果メッセージや他行の状態を巻き込まないため)。
1820
+ pending: function (next, toggleEl) {
1821
+ // **表示済みの値に戻したなら「反映中」ではない。** ON→OFF と押し直した場合、
1822
+ // 画面が出している値と一致するので待つものが無い。ここで消さないと、
1823
+ // 何も保留していないのに (反映中) が残り、リロードで消えて不可解に見える。
1824
+ var back = next === (s.gold === true);
1825
+ if (back) clearPendingGold(s.ref);
1826
+ else markPendingGold(s.ref, next);
1827
+ var card = toggleEl && toggleEl.parentNode;
1828
+ var badges = card && card.querySelector(".ds-badges");
1829
+ if (!badges) return;
1830
+ var b = badges.querySelector(".badge.ds-gold, .badge.ds-excluded, .badge.ds-undeclared");
1831
+ var spec = pendingBadgeSpec(next, back);
1832
+ if (b) { b.className = spec.cls; b.textContent = spec.text; }
1833
+ else badges.insertBefore(el("span", { class: spec.cls, text: spec.text }), badges.firstChild);
1834
+ },
1699
1835
  });
1700
1836
  }
1701
1837
 
@@ -1715,7 +1851,7 @@ const CLIENT_JS = `
1715
1851
  // 「足したい案件でだけ足せない」という逆転になっていた。
1716
1852
  // 押しても必ず失敗するもの(別ツールが宣言済み)は usable() で弾く。
1717
1853
  if (!SITE.intake) return null;
1718
- var tools = SITE.tools || {};
1854
+ var tools = fleetVal("tools") || {};
1719
1855
  var usable = function (cap, tool) { var v = tools[cap]; return !v || v === "none" || v === tool; };
1720
1856
  var box = el("div", {});
1721
1857
  var used = false;
@@ -1920,7 +2056,7 @@ const CLIENT_JS = `
1920
2056
  function renderExternalAdders() {
1921
2057
  var box = el("div", {});
1922
2058
  if (!SITE.intake) return box;
1923
- var tools = SITE.tools || {};
2059
+ var tools = fleetVal("tools") || {};
1924
2060
  var usable = function (cap, tool) { var v = tools[cap]; return !v || v === "none" || v === tool; };
1925
2061
 
1926
2062
  // **Slack は tools 宣言で隠さない。** tools.チャット は能力ごとに1値しか持てないので、
@@ -1990,9 +2126,14 @@ const CLIENT_JS = `
1990
2126
  // 片方だけ直したときにずれる(既存の goldToggle で使っている作法をそのまま一般化したもの)。
1991
2127
 
1992
2128
  /**
1993
- * サーバへ設定変更を送る。**成功しても一覧やバッジは書き換えない**
2129
+ * サーバへ設定変更を送る。**一覧は書き換えない**
1994
2130
  * (接続状況は夜間バッチのスナップショットなので、「もう効いている」と誤解させないため)。
1995
- * 呼び出し側は onDone で自分のボタンの向きだけを反転させる。
2131
+ * 呼び出し側は onDone で自分のボタンの向きを反転させる。
2132
+ *
2133
+ * **例外はバッジの先出し。** opts.pending を持つ操作(いまは Gold の切り替えだけ)は、
2134
+ * 押した内容を「(反映中)」として先に出す。出してよいのは (1) 保存の成否がサーバで
2135
+ * 確定していて推測が入らない (2) **確定した表示と見た目で区別できる**、の両方を満たす
2136
+ * ときだけ。区別しないと、リロードで消えたときに「戻された」と読まれる。
1996
2137
  */
1997
2138
  async function sendConfig(payload, msg, btn, onDone) {
1998
2139
  var intake = SITE.intake;
@@ -2059,7 +2200,9 @@ const CLIENT_JS = `
2059
2200
 
2060
2201
  /**
2061
2202
  * ON/OFF のトグル。**戻す操作を必ず残す**(誤って有効にしたとき翌朝まで止められないのは許容できない)。
2062
- * @param opts { on, labelOn, labelOff, confirmOn, build(next) }
2203
+ * @param opts { on, labelOn, labelOff, confirmOn, build(next), pending(next, wrapEl) }
2204
+ * pending … 保存できた直後に、その行のバッジだけを「(反映中)」へ差し替える任意のフック。
2205
+ * 持たない操作は従来どおりボタンの向きだけを反転する(バッジは触らない)。
2063
2206
  */
2064
2207
  function stateToggle(opts) {
2065
2208
  var wrap = el("div", { class: "ds-toggle" });
@@ -2077,9 +2220,12 @@ const CLIENT_JS = `
2077
2220
  // 読み始める方向(危険)だけ確認する。止める方向は素通し
2078
2221
  if (next && opts.confirmOn && !confirmDanger(opts.confirmOn)) { msg.textContent = ""; return; }
2079
2222
  sendConfig(opts.build(next), msg, btn, function () {
2080
- // **押した内容をボタンに反映し、逆向きの操作を残す。** バッジや一覧は触らない
2223
+ // **押した内容をボタンに反映し、逆向きの操作を残す。**
2081
2224
  on = next;
2082
2225
  paint();
2226
+ // バッジは「反映中」として先出しする(opts.pending がある操作だけ)。
2227
+ // **カードごと作り直さない。** 上の msg に出した結果表示が消えてしまう
2228
+ if (opts.pending) opts.pending(next, wrap);
2083
2229
  });
2084
2230
  });
2085
2231
  wrap.appendChild(btn);
@@ -2173,11 +2319,125 @@ const CLIENT_JS = `
2173
2319
  return box;
2174
2320
  }
2175
2321
 
2322
+ /* ---------------------------------------------------------------------------
2323
+ * Gold切り替えの「反映中」表示。
2324
+ *
2325
+ * 押しても一覧のバッジが変わるのは fleet-status.yml が完走したあと(1〜2分)。
2326
+ * その間バッジが古いままだと「押したのに何も起きない」に見える。
2327
+ *
2328
+ * **推測ではない。** Lambda は書き込む前に Bot の在籍を確認し、ダメなら 400 を返すので、
2329
+ * 200 が返った時点で channels.json は確実に書き換わっている。ここで出すのは
2330
+ * 「確定した事実の先出し」であって楽観的な当て推量ではない。
2331
+ *
2332
+ * **確定したバッジと同じ見た目にはしない。** 30秒後にリロードした人には、付けたはずの
2333
+ * ラベルが消えて見える——「変えたはずなのに違う値が出ている」という、この画面が
2334
+ * 繰り返し警告してきた誤読そのものになる。だから (反映中) を付けて区別し、
2335
+ * さらに sessionStorage に持たせてリロードしても残す。
2336
+ *
2337
+ * 消えるのは「fleet-status が追いついたとき」か「TTL 切れ」。TTL が無いと、
2338
+ * ワークフローが落ちている案件で永久に「反映中」が残り、嘘の表示になる。
2339
+ * ------------------------------------------------------------------------- */
2340
+ // **キーは案件別にする。** Slackの共有チャンネルは複数案件のCortexに同時に載りうるので、
2341
+ // 案件をまたぐキーにすると、案件Aで押した「反映中」が案件Bの同じ行に出る
2342
+ // (チュートリアルの既読キーが同じ理由で案件別になっている)。
2343
+ var PENDING_KEY = "cortex.pendingGold:" + ((SITE.intake && SITE.intake.projectKey) || "");
2344
+ var PENDING_TTL_MS = 15 * 60 * 1000;
2345
+ /**
2346
+ * 保存する形: { "<チャンネルID>": { value: boolean, at: number } }
2347
+ * at が無い/0 のエントリは期限切れとして落とす。
2348
+ * **型では守れない**(このコードは render.ts のテンプレートリテラル内で、tsc が見ない)。
2349
+ * 守るのはテストの仕事。
2350
+ */
2351
+
2352
+ /**
2353
+ * 先出しバッジの見た目。**1箇所に集約する**——文言とクラスを2箇所に書くと片方だけ直る。
2354
+ *
2355
+ * **「反映中」とは書かない。** この画面の既存語彙では「反映」が2つの意味で使われていて、
2356
+ * 短いほう(数分)には必ず「一覧への」が付いている(サーバ文言「次回の Gold 昇格
2357
+ * (毎晩22時ごろ)から反映されます。一覧への反映には数分かかります」)。修飾なしの
2358
+ * 「反映中」だと、22時の蒸留の話と読めてしまう。
2359
+ *
2360
+ * とくに OFF 方向で害が大きい。**止めた時点で蒸留に対しては完全に効いている**
2361
+ * (Slack はミラーせず Gold 昇格が直接読むため)。待っているのは画面だけなので、
2362
+ * 「除外(反映中)」は「まだ除外されていない」と逆に読める。
2363
+ */
2364
+ function pendingBadgeSpec(value, settled) {
2365
+ return {
2366
+ cls: "badge " + (value ? "ds-gold" : "ds-excluded") + (settled ? "" : " is-pending"),
2367
+ text: (value ? "Gold昇格の読み取り対象" : "除外") + (settled ? "" : "(画面反映待ち)"),
2368
+ };
2369
+ }
2370
+
2371
+ function readPending() {
2372
+ try {
2373
+ var raw = sessionStorage.getItem(PENDING_KEY);
2374
+ if (!raw) return {};
2375
+ var obj = JSON.parse(raw);
2376
+ if (!obj || typeof obj !== "object") return {};
2377
+ var now = Date.now();
2378
+ var out = {};
2379
+ for (var k in obj) {
2380
+ if (!Object.prototype.hasOwnProperty.call(obj, k)) continue;
2381
+ var e = obj[k];
2382
+ if (e && typeof e === "object" && now - (e.at || 0) < PENDING_TTL_MS) out[k] = e;
2383
+ }
2384
+ return out;
2385
+ } catch (_) {
2386
+ // sessionStorage が使えない環境(プライベートモード等)でも画面は出す
2387
+ return {};
2388
+ }
2389
+ }
2390
+ function writePending(map) {
2391
+ try { sessionStorage.setItem(PENDING_KEY, JSON.stringify(map)); } catch (_) {}
2392
+ }
2393
+ function clearPendingGold(ref) {
2394
+ if (!ref) return;
2395
+ var map = readPending();
2396
+ if (!(ref in map)) return;
2397
+ delete map[ref];
2398
+ writePending(map);
2399
+ }
2400
+ /** 押した内容を控える。fleet-status が追いつくまでの間だけ効く */
2401
+ function markPendingGold(ref, value) {
2402
+ if (!ref) return;
2403
+ var map = readPending();
2404
+ map[ref] = { value: value === true, at: Date.now() };
2405
+ writePending(map);
2406
+ }
2407
+ /**
2408
+ * その行の「反映中」を引く。**実データが追いついていたら控えを捨てる。**
2409
+ * 捨てないと、正しくなった後も (反映中) が残り続ける。
2410
+ */
2411
+ function pendingGoldFor(s) {
2412
+ if (!s || !s.ref) return null;
2413
+ var map = readPending();
2414
+ var e = map[s.ref];
2415
+ if (!e) return null;
2416
+ // **「値が一致したら捨てる」だけでは足りない。** 別の人が直後に逆を押して、
2417
+ // その結果で追いついた場合、値が一致しないので控えが残り、TTL切れまで
2418
+ // 実際とは違う値を出し続ける。**押した時点より新しいスナップショットが来たら、
2419
+ // 値が何であれ控えは用済み**——サーバの答えのほうが新しいので、そちらを信じる。
2420
+ var snapshot = fleetVal("generatedAt");
2421
+ var snapAt = snapshot ? Date.parse(snapshot) : NaN;
2422
+ var stale = !isNaN(snapAt) && snapAt >= (e.at || 0);
2423
+ if (s.gold === e.value || stale) {
2424
+ delete map[s.ref];
2425
+ writePending(map);
2426
+ return null;
2427
+ }
2428
+ return e;
2429
+ }
2430
+
2176
2431
  function extSourceCard(s, meta) {
2177
2432
  var badges = el("div", { class: "ds-badges" });
2433
+ var pending = pendingGoldFor(s);
2178
2434
  // 「意図的に外した」と「宣言し忘れ」を分けて出す。同じ「除外」と書くと、
2179
2435
  // 判断されていないだけのものが「判断済み」に見えてしまう。
2180
- if (s.gold === true) badges.appendChild(el("span", { class: "badge ds-gold", text: "Gold昇格の読み取り対象" }));
2436
+ if (pending) {
2437
+ var spec = pendingBadgeSpec(pending.value, false);
2438
+ badges.appendChild(el("span", { class: spec.cls, text: spec.text }));
2439
+ }
2440
+ else if (s.gold === true) badges.appendChild(el("span", { class: "badge ds-gold", text: "Gold昇格の読み取り対象" }));
2181
2441
  else if (s.goldState === "undeclared") badges.appendChild(el("span", { class: "badge ds-undeclared", text: "未宣言(対象外)" }));
2182
2442
  else if (s.gold === false) badges.appendChild(el("span", { class: "badge ds-excluded", text: "除外" }));
2183
2443
  if (s.notify === true) badges.appendChild(el("span", { class: "badge ds-notify", text: "通知先" }));
@@ -2241,9 +2501,166 @@ const CLIENT_JS = `
2241
2501
  }
2242
2502
 
2243
2503
  // ---------- 操作タブ(接続マップ・データソースと自動化・取り込み。今後この受け皿に機能を足す) ----------
2504
+ /**
2505
+ * 接続状況をライブで取りに行き、取れたら操作タブを描き直す。
2506
+ *
2507
+ * **静的ビルドで描き切った後に呼ぶ。** 待たせないため、そして失敗しても既に画面が
2508
+ * 出ているため。1タブ表示につき1回だけで、ポーリングはしない(GitHubトークンは
2509
+ * 組織で共有していて、枯渇すると同じ組織の全案件のビルド・同期が止まる)。
2510
+ */
2511
+ /**
2512
+ * ライブ応答の最低限の正規化。
2513
+ *
2514
+ * **静的経路は build.ts が同じことをしている。** あちらは要素を許可リストで組み直し、
2515
+ * 非オブジェクトを落として型を強制する。ライブ経路にはそれが無いので、生成側
2516
+ * (別リポの cortex-engine)が壊れた1件を混ぜると、要素を読む段で TypeError になり
2517
+ * **操作タブが丸ごと出なくなる**(データソースだけでなく、その後の取り込みフォームも
2518
+ * 鍵の登録も消える)。fleet-passthrough.test.mjs が「壊れた1件でビルド全体が落ちると
2519
+ * 案件のビューアが丸ごと出なくなる」と書いているのと同じ事故を、ライブ経路で起こさない。
2520
+ *
2521
+ * ここは build.ts の許可リストを写さない(2枚になると列挙し忘れが静かに消える)。
2522
+ * **落とすのは「読めない形のもの」だけ**——配列の非オブジェクト要素と、
2523
+ * 文字列前提の配列に混ざった非文字列。
2524
+ */
2525
+ /** 入力中・送信中の節か(フォーカスがある/入力に値がある/進行中の表示がある) */
2526
+ function isBeingEdited(node) {
2527
+ if (node.contains(document.activeElement) && document.activeElement !== document.body) return true;
2528
+ var inputs = node.querySelectorAll("input, textarea");
2529
+ for (var i = 0; i < inputs.length; i++) { if (inputs[i].value) return true; }
2530
+ var msgs = node.querySelectorAll(".ds-toggle-msg");
2531
+ for (var m = 0; m < msgs.length; m++) { if (msgs[m].textContent) return true; }
2532
+ return false;
2533
+ }
2534
+
2535
+ /** 文字列だけを並べる配列。非文字列が混ざると "null" や "[object Object]" が表示される */
2536
+ var STRING_LIST_FIELDS = { meetingAliases: 1, driveFolderIds: 1 };
2537
+
2538
+ function normalizeFleet(fleet) {
2539
+ if (!fleet || typeof fleet !== "object") return null;
2540
+ var out = {};
2541
+ for (var k in fleet) {
2542
+ if (!Object.prototype.hasOwnProperty.call(fleet, k)) continue;
2543
+ var v = fleet[k];
2544
+ if (!Array.isArray(v)) { out[k] = v; continue; }
2545
+ out[k] = v
2546
+ .filter(function (item) { return item != null && typeof item === "object" && !Array.isArray(item); })
2547
+ .map(function (item) {
2548
+ var copy = {};
2549
+ for (var f in item) {
2550
+ if (!Object.prototype.hasOwnProperty.call(item, f)) continue;
2551
+ var fv = item[f];
2552
+ // 画面が文字列として並べる配列(会議の別名・DriveフォルダID)に非文字列が
2553
+ // 混ざると "null" や "[object Object]" が表示される。build.ts:178 が
2554
+ // 名指しで防いでいる罠なので、ライブ側でも同じ扱いにする
2555
+ if (Array.isArray(fv)) {
2556
+ copy[f] = STRING_LIST_FIELDS[f]
2557
+ ? fv.filter(function (x) { return typeof x === "string" && x !== ""; })
2558
+ : fv.filter(function (x) { return x != null; });
2559
+ } else {
2560
+ copy[f] = fv;
2561
+ }
2562
+ }
2563
+ return copy;
2564
+ });
2565
+ }
2566
+ return out;
2567
+ }
2568
+
2569
+ /**
2570
+ * ライブ値が届いたときの差し替え。**画面全体を描き直さない。**
2571
+ *
2572
+ * 当初は route() を呼んでいたが、これは renderList() → app.innerHTML = "" で
2573
+ * 操作タブを丸ごと作り直す。操作タブは DOM に状態を持つ設計なので
2574
+ * (貼りかけのトークン・「保存中…」・押した直後のボタンの向き)、
2575
+ * **保存中に応答が届くと入力が消え、成功メッセージは切り離された古いノードに書かれて
2576
+ * 画面に出ない**——保存は成功しているのに「押したのに何も起きなかった」と見える。
2577
+ * 差し替えるのはデータソース節と接続マップの注記だけにする。
2578
+ */
2579
+ function refreshDataSources() {
2580
+ if (listState.tab !== "ops") return;
2581
+ var old = app.querySelector(".datasources");
2582
+ // **触っている最中は差し替えない。** この節の中には追加フォームの入力欄があり、
2583
+ // 貼りかけのURLや「保存中…」の表示ごと作り直してしまう。保存が成功していても
2584
+ // 結果メッセージは切り離された古いノードに書かれ、画面には出ない——
2585
+ // 「押したのに何も起きなかった」と見えて、もう一度貼って押す(=二重保存)に誘導する。
2586
+ // 見送っても値は LIVE_FLEET に入っているので、次に操作タブを描くときに反映される。
2587
+ if (old && isBeingEdited(old)) return;
2588
+ var next = buildDataSources();
2589
+ if (old && next) old.parentNode.replaceChild(next, old);
2590
+ else if (old) old.parentNode.removeChild(old);
2591
+ else if (next) {
2592
+ // 静的では空でライブで出るようになった場合。接続マップの直後に入れる
2593
+ var map = app.querySelector(".conn-map");
2594
+ if (map && map.nextSibling) app.insertBefore(next, map.nextSibling);
2595
+ else app.insertBefore(next, app.firstChild);
2596
+ }
2597
+ updateConnMapAsOf();
2598
+ updateSecretStates();
2599
+ }
2600
+
2601
+ /**
2602
+ * 鍵カードの「現在: ◯◯ から取得中」だけを更新する。
2603
+ * **カードごと作り直さない**(貼りかけのトークンが消える)。表示は1行なので差し替えで足りる。
2604
+ */
2605
+ function updateSecretStates() {
2606
+ var sources = fleetVal("secretSources") || {};
2607
+ var cards = app.querySelectorAll("[data-secret-kind]");
2608
+ for (var i = 0; i < cards.length; i++) {
2609
+ var kind = cards[i].getAttribute("data-secret-kind");
2610
+ var line = cards[i].querySelector(".sec-state");
2611
+ if (!line) continue;
2612
+ var src = sources[kind] || "";
2613
+ line.className = src
2614
+ ? "sec-state" + (src === "Secrets Manager" ? " is-managed" : "")
2615
+ : "sec-state is-none";
2616
+ line.textContent = src ? "現在: " + src + " から取得中" : "現在: 未設定";
2617
+ }
2618
+ }
2619
+
2620
+ /**
2621
+ * 取得中フラグ。**完了しても寝かせたままにしない。**
2622
+ * 一度 true のままだと、タブを移動して戻ってきても再取得せず、設定を変えた人が
2623
+ * その場で反映を確かめる手段がフルリロードだけになる。取得が終わったら倒して、
2624
+ * **操作タブに入り直したときだけ**もう一度取りに行く(ポーリングはしない)。
2625
+ */
2626
+ var liveFleetRequested = false;
2627
+ function loadLiveFleet() {
2628
+ if (liveFleetRequested) return;
2629
+ var intake = SITE.intake;
2630
+ if (!intake) return;
2631
+ // **fetch の有無を確かめてから呼ぶ。** テスト環境(JSDOM)は window.fetch を持たない。
2632
+ // 素で呼ぶと描画中の ReferenceError になり、「画面が例外を出さずに描き切る」検査を割る。
2633
+ if (typeof fetch !== "function") return;
2634
+ liveFleetRequested = true;
2635
+ var pfx = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&");
2636
+ fetch(pfx + "op=state&t=" + encodeURIComponent(intake.token), {
2637
+ method: "POST",
2638
+ headers: { "content-type": "application/json" },
2639
+ body: "{}",
2640
+ })
2641
+ // **非2xxも失敗として扱う。** ビューアとLambdaの版は独立に進むので、
2642
+ // op=state 未対応のLambdaは 400 を返す。通信エラーだけを見ていると空表示になる。
2643
+ .then(function (res) { if (!res.ok) throw new Error("http " + res.status); return res.json(); })
2644
+ .then(function (j) {
2645
+ if (!j || !j.fleet) throw new Error("no fleet");
2646
+ LIVE_FLEET = normalizeFleet(j.fleet);
2647
+ LIVE_FLEET_FAILED = false;
2648
+ liveFleetRequested = false;
2649
+ refreshDataSources();
2650
+ })
2651
+ .catch(function () {
2652
+ // 静的ビルドの値で描き続ける。ただし「古いかもしれない」ことは画面に出す
2653
+ LIVE_FLEET = null;
2654
+ LIVE_FLEET_FAILED = true;
2655
+ liveFleetRequested = false;
2656
+ refreshDataSources();
2657
+ });
2658
+ }
2659
+
2244
2660
  function renderOps() {
2245
2661
  renderConnMap();
2246
2662
  renderDataSources();
2663
+ loadLiveFleet();
2247
2664
  if (SITE.intake) {
2248
2665
  app.appendChild(el("p", { class: "ops-intro", text: "Cortex にコンテキストを取り込む操作です。ファイルを選ぶかテキストを貼って送信すると、自動で取り込まれます(数分で反映)。" }));
2249
2666
  app.appendChild(el("div", { class: "section-label", text: "取り込み" }));
@@ -2267,7 +2684,21 @@ const CLIENT_JS = `
2267
2684
  icon: "📋",
2268
2685
  title: "Backlog のAPIキー",
2269
2686
  desc: "課題・Wiki・ドキュメントを毎晩同期するために使います。",
2270
- how: "Backlog → 個人設定 → API で発行",
2687
+ how: "Backlog → 個人設定 → API で発行(この案件のプロジェクトが見えるアカウントで)",
2688
+ // **案件の担当者が自分で繋ぐ。** 以前は艦隊共通の読み取りアカウントを1つ運用していたが、
2689
+ // 案件が増えるたびに「その1人がプロジェクトへ参加追加してキーを配る」ことになり、
2690
+ // セットアップが一箇所に詰まる。**繋ぐのは、その案件を持っている人**にする。
2691
+ //
2692
+ // 代償は「その人が離れると同期が止まる」こと。ただしこれは**担当が変わったときに
2693
+ // 次の人が入れ直せば済む**話で、誰か1人に依存し続けるより回復しやすい。
2694
+ selfServe: {
2695
+ text:
2696
+ "この案件を担当しているあなたのアカウントで発行して、ここに貼ってください。" +
2697
+ "管理者への依頼は要りません。",
2698
+ note:
2699
+ "担当が変わったら、新しい担当者が同じ手順で入れ直してください" +
2700
+ "(前の担当者のアカウントが無効になると同期が止まります)。",
2701
+ },
2271
2702
  },
2272
2703
  "external-sources": {
2273
2704
  icon: "🐙",
@@ -2299,7 +2730,7 @@ const CLIENT_JS = `
2299
2730
  // **いま何で動いているか**を先に出す。入れ直したのに反映されていない状態が
2300
2731
  // 画面で分かるようにする(接続状況は夜間バッチのスナップショットなので、
2301
2732
  // 保存の直後は前日の値のまま。時刻は上の節に出している)。
2302
- var src = (SITE.secretSources || {})[kind] || "";
2733
+ var src = (fleetVal("secretSources") || {})[kind] || "";
2303
2734
  var state = src
2304
2735
  ? el("p", { class: "sec-state" + (src === "Secrets Manager" ? " is-managed" : ""),
2305
2736
  text: "現在: " + src + " から取得中" })
@@ -2349,14 +2780,19 @@ const CLIENT_JS = `
2349
2780
  }
2350
2781
  });
2351
2782
 
2352
- return el("div", { class: "ops-card" }, [
2783
+ // ライブ取得後に「現在: …」だけを差し替えるための目印(カードごと作り直すと入力が消える)
2784
+ return el("div", { class: "ops-card", "data-secret-kind": kind }, [
2353
2785
  el("div", { class: "ops-card-head" }, [
2354
2786
  el("span", { class: "ops-card-icon", text: meta.icon }),
2355
2787
  el("h3", { class: "ops-card-title", text: meta.title }),
2356
2788
  ]),
2357
2789
  el("p", { class: "ops-card-desc", text: meta.desc }),
2358
2790
  state,
2791
+ // **未設定のときだけ、先に試すべき手を出す。** 既に動いているカードに
2792
+ // 「まずこれをやれ」と出すと、直す必要が無いものを触らせることになる
2793
+ !src && meta.selfServe ? el("p", { class: "sec-preferred", text: meta.selfServe.text }) : null,
2359
2794
  el("p", { class: "sec-how", text: meta.how }),
2795
+ meta.selfServe ? el("p", { class: "sec-how", text: meta.selfServe.note }) : null,
2360
2796
  input,
2361
2797
  el("div", { class: "ds-toggle" }, [btn, msg]),
2362
2798
  ]);
@@ -2381,6 +2817,26 @@ const CLIENT_JS = `
2381
2817
  if (target && target.scrollIntoView) target.scrollIntoView({ behavior: "smooth", block: "center" });
2382
2818
  });
2383
2819
  app.appendChild(wrap);
2820
+ updateConnMapAsOf();
2821
+ }
2822
+
2823
+ /**
2824
+ * 接続マップに「いつ時点か」を付ける/外す。
2825
+ *
2826
+ * **この図はビルド時に焼かれている。** 下の一覧はライブに差し替わるので、ライブが
2827
+ * 取れているときは図だけが古い。同じ画面で図が⚠️・直下が「接続OK」になる状態を
2828
+ * 黙って出さないよう、図がいつ時点かを明示する(図の再描画は別途)。
2829
+ * ライブが取れていないときは図と一覧が同じ時点なので、注記は出さない。
2830
+ */
2831
+ function updateConnMapAsOf() {
2832
+ var wrap = app.querySelector(".conn-map");
2833
+ if (!wrap) return;
2834
+ var existing = wrap.querySelector(".conn-map-asof");
2835
+ if (existing) existing.parentNode.removeChild(existing);
2836
+ if (!LIVE_FLEET || !SITE.fleetGeneratedAt) return;
2837
+ var mapAsOf = dsFmtTime(SITE.fleetGeneratedAt);
2838
+ if (!mapAsOf) return;
2839
+ wrap.appendChild(el("p", { class: "conn-map-asof", text: "この図は " + mapAsOf + " 時点です(下の一覧は最新)。" }));
2384
2840
  }
2385
2841
  function opsCard(icon, title, desc, kind, btnLabel) {
2386
2842
  var btn = el("button", { class: "ops-card-btn", text: "+ " + btnLabel });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.4.57",
3
+ "version": "0.4.59",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,