fix(console): 駕駛艙首頁改讀 live 一手資料,stale 殘骸誠實標示
leo 抱怨「等你的事」錯了好幾天、首頁總覽對不上實際進度。診斷結論: - dash_wait 沒有活的維護管線(最後寫入 07-04;等leo清單#11 已於 07-05 銷案=誤判,dashboard 卻繼續掛著)——真相源其實是 InkStoneCo sprint 檔 「## 等 leo 清單」(progress-guard 每日核實維護) - dash_task 的 scope:today 沒有日期,07-04 的路線到 07-07 還被當今天的 - 燈號吃到 stale 任務;系統狀況/總覽無 live 信號 修法: - 等你的事:首選 Gitea sprint 等leo清單(合併最新兩檔——07b 開了、🔴 仍 掛 07a;需 GITEA_TOKEN secret);讀不到 fallback dash_wait 必標 age+stale - 今日路線:台北日曆日判 is_today,非今日寫入降級「最後路線(N 天前)」 - 燈號只吃今日寫入任務+心跳+KBDB /health,附 light_reason - 新增系統狀況(KBDB /health、embed enabled:false 誠實顯示、graph /triplets/stats、workflow 數)與總庫規模(entries/wiki_card/triplets)全 live - 聚合判定抽純函式 lib/console-dashboard-model.ts,vitest 15 案例 (含真實 sprint 檔形態:劃掉列/✅ 急迫欄/R1 字母編號/🔴 排序) 驗證:tsc --noEmit 0;vitest 65/66(1 失敗為 main 既有 executor.test.ts, stash 復原後同樣失敗,非本次引入);parser 對 live sprint-2026-07a/07b dry-run 撈出 🔴#13 等 9 項 open、清單維護於 31 分鐘前。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* console 駕駛艙 dashboard 的聚合純函式層(2026-07-07 fix/console-dashboard-live-data)
|
||||
*
|
||||
* 為什麼獨立成 lib:routes/console-dashboard.ts 原本把「解析 + 判定」焊在 route 裡,
|
||||
* 無法單測;本檔只放**純函式**(無 fetch、無 env),route 負責 IO、這裡負責判定,
|
||||
* vitest 可直接餵資料驗證(誠實限制 mindset §7:修 stale bug 要有客觀證據)。
|
||||
*
|
||||
* 本次修的三個 stale 根因(leo 抱怨「等你的事錯了好幾天」的診斷結論):
|
||||
* 1. dash_wait 沒有活的維護管線(最後寫入 2026-07-04;等leo清單#11 已於 07-05 銷案=誤判,
|
||||
* dashboard 卻繼續掛著它)——「等 leo」的真相源其實是 InkStoneCo sprint 檔的
|
||||
* 「## 等 leo 清單」表格(progress-guard 每日核實維護)。→ 改讀 Gitea sprint 檔,
|
||||
* 讀不到才 fallback dash_wait 且必標 age,不假裝是今天的。
|
||||
* 2. dash_task 的 scope:"today" 沒有日期概念——07-04 寫入的「今日路線」到 07-07 還被
|
||||
* 當成今天的。→ 以台北日曆日判定 is_today,非今日寫入的任務誠實降級為「最後路線(N 天前)」。
|
||||
* 3. 燈號 hasBlocked/hasLagMark 吃到 stale 任務——幾天前的 blocked 會讓今天亮紅燈。
|
||||
* → 燈號只吃「今日寫入」的任務 + 心跳(心跳 dash_beat 是唯一有活管線的 dash_* 資料)。
|
||||
*/
|
||||
|
||||
export interface KbdbEntry {
|
||||
id: string;
|
||||
content: string | null;
|
||||
entry_type: string;
|
||||
created_at: string | number; // leo21c 實測是 epoch 秒(number);防禦性也吃 sqlite/ISO 字串
|
||||
}
|
||||
|
||||
/** created_at 實測(leo21c KBDB)=epoch 秒數 number;防禦性同時吃 epoch 毫秒與
|
||||
* 'YYYY-MM-DD HH:MM:SS'(UTC)/ ISO 字串(別的部署可能不同 schema 版本)。 */
|
||||
export function parseCreatedAtMs(s: string | number | null | undefined): number | null {
|
||||
if (s === null || s === undefined || s === '') return null;
|
||||
if (typeof s === 'number') return s < 1e12 ? s * 1000 : s; // 秒 vs 毫秒
|
||||
if (/^\d+$/.test(s)) {
|
||||
const n = Number(s);
|
||||
return n < 1e12 ? n * 1000 : n;
|
||||
}
|
||||
const iso = /T/.test(s) ? s : `${s.replace(' ', 'T')}Z`;
|
||||
const ms = Date.parse(iso);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
export function parseJsonContent(e: KbdbEntry): Record<string, unknown> | null {
|
||||
if (!e.content) return null;
|
||||
try {
|
||||
const v = JSON.parse(e.content);
|
||||
return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 台北(UTC+8)日曆日 key,用來判定「這筆 dash_task 是不是今天寫的」。 */
|
||||
export function taipeiDayKey(ms: number): string {
|
||||
return new Date(ms + 8 * 3600 * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function agoMinutes(nowMs: number, ms: number | null): number {
|
||||
return ms === null ? -1 : Math.max(0, Math.round((nowMs - ms) / 60000));
|
||||
}
|
||||
|
||||
// ── 今日路線(dash_task)───────────────────────────────────────────────────────
|
||||
|
||||
export interface DashTask {
|
||||
title: string;
|
||||
status: string;
|
||||
order: number;
|
||||
scope: 'today' | 'week';
|
||||
at_ms: number | null;
|
||||
age_minutes: number;
|
||||
/** 台北日曆日判定:這筆的最新寫入是不是「今天」寫的 */
|
||||
is_today_write: boolean;
|
||||
}
|
||||
|
||||
export interface RouteModel {
|
||||
tasks: DashTask[];
|
||||
/** 最新一筆 dash_task 寫入距今分鐘數;-1 = 全庫沒有 dash_task */
|
||||
updated_ago_minutes: number;
|
||||
/** 最新寫入是否發生在台北「今天」——false = 整包路線是殘資料 */
|
||||
is_today: boolean;
|
||||
today_done: number;
|
||||
today_total: number;
|
||||
}
|
||||
|
||||
/** dash_task 聚合:同 title 取最新(list 已 created_at DESC → first-seen 即最新),
|
||||
* 但「今日完成 n/m」只數今天(台北)寫入的任務——幾天前的殘路線不假裝是今天的。 */
|
||||
export function buildRouteModel(entries: KbdbEntry[], nowMs: number): RouteModel {
|
||||
const todayKey = taipeiDayKey(nowMs);
|
||||
const byTitle = new Map<string, DashTask>();
|
||||
for (const e of entries) {
|
||||
const j = parseJsonContent(e);
|
||||
const title = typeof j?.title === 'string' ? (j.title as string) : null;
|
||||
if (!title || byTitle.has(title)) continue;
|
||||
const ms = parseCreatedAtMs(e.created_at);
|
||||
byTitle.set(title, {
|
||||
title,
|
||||
status: typeof j?.status === 'string' ? (j.status as string) : 'todo',
|
||||
order: typeof j?.order === 'number' ? (j.order as number) : 999,
|
||||
scope: j?.scope === 'week' ? 'week' : 'today',
|
||||
at_ms: ms,
|
||||
age_minutes: agoMinutes(nowMs, ms),
|
||||
is_today_write: ms !== null && taipeiDayKey(ms) === todayKey,
|
||||
});
|
||||
}
|
||||
const tasks = [...byTitle.values()].sort((a, b) =>
|
||||
a.scope !== b.scope ? (a.scope === 'today' ? -1 : 1) : a.order - b.order,
|
||||
);
|
||||
const newestMs = tasks.reduce<number | null>((m, t) => (t.at_ms !== null && (m === null || t.at_ms > m) ? t.at_ms : m), null);
|
||||
const isToday = newestMs !== null && taipeiDayKey(newestMs) === todayKey;
|
||||
const todayTasks = tasks.filter((t) => t.scope === 'today' && t.is_today_write);
|
||||
return {
|
||||
tasks,
|
||||
updated_ago_minutes: agoMinutes(nowMs, newestMs),
|
||||
is_today: isToday,
|
||||
today_done: todayTasks.filter((t) => t.status === 'done').length,
|
||||
today_total: todayTasks.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 等你的事 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WaitingItem {
|
||||
title: string;
|
||||
/** Gitea sprint 來源才有:等leo清單編號("13"、"R1"——07b 起用字母前綴)與急迫 emoji */
|
||||
id?: string;
|
||||
urgency?: string;
|
||||
/** 合併多個 sprint 檔時標出處(如 "sprint-2026-07a.md") */
|
||||
sprint?: string;
|
||||
}
|
||||
|
||||
export interface WaitingModel {
|
||||
items: WaitingItem[];
|
||||
/** 'gitea_sprint' = 讀到活資料源;'kbdb_dash_wait' = fallback(必標 age);'none' = 管線未接 */
|
||||
source: 'gitea_sprint' | 'kbdb_dash_wait' | 'none';
|
||||
/** 資料源上次維護距今分鐘;-1 = 不明 */
|
||||
updated_ago_minutes: number;
|
||||
/** true = 這包資料超過閾值沒人維護,頁面必須明示「可能過時」 */
|
||||
stale: boolean;
|
||||
/** gitea_sprint 來源:實際讀了哪些 sprint 檔(新→舊) */
|
||||
sprint_files?: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
const URGENCY_EMOJI = /(🔴|🟡|🟢|⚪)/u;
|
||||
/** 急迫欄含這些字樣=已結案,不再是「等你的事」 */
|
||||
const CLOSED_MARKERS = /(✅|已完成|已解|銷案|已銷)/u;
|
||||
|
||||
/** 從 sprint md 撈「## 等 leo 清單」表格(含「(RAG 線)」等帶註記變體),回傳仍 open 的項目。
|
||||
* 規則(對照 sprint-2026-07a/07b.md 實際維護慣例):
|
||||
* - 編號欄吃英數(07a 純數字 "13";07b 起 "R1" 字母前綴)
|
||||
* - 事項欄以 ~~ 開頭(整項劃掉)→ 已結案,跳過
|
||||
* - 急迫欄含 ✅/已完成/已解/銷案 → 已結案,跳過
|
||||
* 解析失敗(找不到段落/表格)→ 回 null,caller 誠實 fallback,不硬湊。排序交給 sortWaitingItems。 */
|
||||
export function parseSprintWaitingTable(md: string, sprintFile?: string): WaitingItem[] | null {
|
||||
const secIdx = md.search(/^##\s*等\s*leo\s*清單/mu);
|
||||
if (secIdx < 0) return null;
|
||||
const section = md.slice(secIdx);
|
||||
const lines = section.split('\n');
|
||||
const items: WaitingItem[] = [];
|
||||
let sawTable = false;
|
||||
for (const line of lines.slice(1)) {
|
||||
if (/^#{2,3}\s/.test(line) && !/^##\s*等/.test(line)) break; // 下一個段落,停
|
||||
const m = line.match(/^\|\s*([A-Za-z]?\d+)\s*\|(.*)\|(.*)\|\s*$/u);
|
||||
if (!m) continue;
|
||||
sawTable = true;
|
||||
const id = m[1];
|
||||
const item = m[2].trim();
|
||||
const urgency = m[3].trim();
|
||||
if (item.startsWith('~~')) continue;
|
||||
if (CLOSED_MARKERS.test(urgency)) continue;
|
||||
items.push({
|
||||
id,
|
||||
urgency: urgency.match(URGENCY_EMOJI)?.[1] ?? '',
|
||||
title: cleanWaitingTitle(item),
|
||||
sprint: sprintFile,
|
||||
});
|
||||
}
|
||||
if (!sawTable) return null;
|
||||
return sortWaitingItems(items);
|
||||
}
|
||||
|
||||
/** 🔴 > 🟡 > 其他;同急迫維持傳入順序(stable sort,現役 sprint 的項目先傳先顯示)。 */
|
||||
export function sortWaitingItems(items: WaitingItem[]): WaitingItem[] {
|
||||
const rank = (u?: string) => (u === '🔴' ? 0 : u === '🟡' ? 1 : 2);
|
||||
return items
|
||||
.map((it, i) => ({ it, i }))
|
||||
.sort((a, b) => rank(a.it.urgency) - rank(b.it.urgency) || a.i - b.i)
|
||||
.map((x) => x.it);
|
||||
}
|
||||
|
||||
/** 事項欄清洗:去 markdown 粗體/劃掉/行內 code、去開頭急迫 emoji(急迫欄已有,別重複)、
|
||||
* 截前 80 字(cell 動輒數百字,儀表板只要標題),截斷若留下未閉合的「(」順手剪掉。 */
|
||||
export function cleanWaitingTitle(raw: string): string {
|
||||
let s = raw.replace(/\*\*/g, '').replace(/~~/g, '').replace(/`/g, '').trim();
|
||||
s = s.replace(/^(?:🔴|🟡|🟢|⚪)\s*/u, '');
|
||||
// 常見格式「**標題**(時間 說明):長解釋…」→ 取第一個「:」前;太短則不切
|
||||
const colon = s.indexOf(':');
|
||||
if (colon >= 12) s = s.slice(0, colon);
|
||||
if (s.length > 80) s = `${s.slice(0, 79)}…`;
|
||||
// 截斷可能留下未閉合的全形括號 → 從最後一個未配對「(」剪掉
|
||||
let depth = 0;
|
||||
let lastOpen = -1;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] === '(') {
|
||||
if (depth === 0) lastOpen = i;
|
||||
depth++;
|
||||
} else if (s[i] === ')') {
|
||||
depth = Math.max(0, depth - 1);
|
||||
}
|
||||
}
|
||||
if (depth > 0 && lastOpen >= 0) s = s.slice(0, lastOpen).trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
/** sprint 檔命名慣例 sprint-YYYY-MM<letter>.md,字典序即時間序。
|
||||
* 取最新 n 個(新→舊):現役 sprint 換檔後,前一個 sprint 的等leo清單常還有未銷案項
|
||||
* (實例:07b 開了、🔴 mira 憑證外洩仍掛在 07a)——只讀最新一檔會漏掉真正在等的事。 */
|
||||
export function pickLatestSprintFiles(names: string[], n = 2): string[] {
|
||||
return names
|
||||
.filter((name) => /^sprint-.*\.md$/.test(name))
|
||||
.sort()
|
||||
.slice(-n)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
/** fallback:dash_wait 同 title 取最新、只留 open——但每項帶 age、整包帶 stale 判定。
|
||||
* 這條路只在 Gitea 讀不到時走;它的資料 2026-07-04 之後就沒人維護,所以 stale 標記是義務。 */
|
||||
export function buildWaitingFallback(entries: KbdbEntry[], nowMs: number): WaitingModel {
|
||||
const byTitle = new Map<string, { status: string; at_ms: number | null }>();
|
||||
for (const e of entries) {
|
||||
const j = parseJsonContent(e);
|
||||
const title = typeof j?.title === 'string' ? (j.title as string) : null;
|
||||
if (!title || byTitle.has(title)) continue;
|
||||
byTitle.set(title, {
|
||||
status: typeof j?.status === 'string' ? (j.status as string) : 'open',
|
||||
at_ms: parseCreatedAtMs(e.created_at),
|
||||
});
|
||||
}
|
||||
const open = [...byTitle.entries()].filter(([, v]) => v.status === 'open');
|
||||
const newestMs = [...byTitle.values()].reduce<number | null>(
|
||||
(m, v) => (v.at_ms !== null && (m === null || v.at_ms > m) ? v.at_ms : m),
|
||||
null,
|
||||
);
|
||||
const ago = agoMinutes(nowMs, newestMs);
|
||||
return {
|
||||
items: open.map(([title]) => ({ title })),
|
||||
source: byTitle.size ? 'kbdb_dash_wait' : 'none',
|
||||
updated_ago_minutes: ago,
|
||||
stale: ago < 0 || ago > 24 * 60,
|
||||
note: byTitle.size
|
||||
? undefined
|
||||
: '管線未接:dash_wait 無資料、Gitea sprint 讀取未設定(GITEA_TOKEN)',
|
||||
};
|
||||
}
|
||||
|
||||
/** 人話 age:分鐘 → 「3 分鐘前 / 5 小時前 / 3 天前」。-1 → 「時間不明」。 */
|
||||
export function humanAge(minutes: number): string {
|
||||
if (minutes < 0) return '時間不明';
|
||||
if (minutes < 60) return `${minutes} 分鐘前`;
|
||||
if (minutes < 48 * 60) return `${Math.round(minutes / 60)} 小時前`;
|
||||
return `${Math.round(minutes / (24 * 60))} 天前`;
|
||||
}
|
||||
@@ -1,35 +1,56 @@
|
||||
/**
|
||||
* arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工)
|
||||
* arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工;
|
||||
* 2026-07-07 fix/console-dashboard-live-data:stale 資料整修,總管交辦)
|
||||
*
|
||||
* 兩個端點,皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
|
||||
* - GET /console/dashboard-data:從 KBDB(走既有 kbdbBase 慣例,同 kbdb-proxy)讀四種
|
||||
* entry_type(dash_beat / dash_task / dash_wait / inbox,租戶 = CONSOLE_TENANT,同
|
||||
* console-auth 的固定租戶模型)並聚合成一包 JSON。
|
||||
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),手機優先、一屏、兩格,
|
||||
* 每 60 秒自動 fetch dashboard-data 刷新。無互動功能,就是儀表板。
|
||||
* - GET /console/dashboard-data:聚合 JSON。
|
||||
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),每 60 秒自動刷新。
|
||||
*
|
||||
* 資料契約(寫入方=總管/cloud-worker/progress-guard 往 /kbdb/entries POST,content 是 JSON 字串):
|
||||
* dash_beat:{"actor":"總管|cloud-worker|progress-guard","event":"start|done","note":"一句"}
|
||||
* → 每 actor 取最新一筆(base list 已按 created_at DESC,first-seen 即最新)。
|
||||
* dash_task:{"title","status":"done|doing|todo|blocked","order":1,"scope":"today|week"}
|
||||
* → 以 title 為 key 取 created 最新(後寫蓋前寫)。
|
||||
* dash_wait:{"title","status":"open|closed"} → 同 title 取最新,只回 open。
|
||||
* inbox :{"text","status":"new|done"} → 計數未處理(status !== 'done')。
|
||||
* ── 2026-07-07 整修:每個區塊都讀「live 一手資料」,讀不到就誠實標示,不擺 stale 殘骸 ──
|
||||
*
|
||||
* 資料源診斷(leo 抱怨「等你的事錯了好幾天」的根因):
|
||||
* - dash_wait(等你的事舊資料源)最後寫入 2026-07-04,**沒有活的維護管線**——等leo清單#11
|
||||
* 已於 07-05 銷案(誤判),dashboard 卻繼續掛著它。真相源其實是 InkStoneCo sprint 檔的
|
||||
* 「## 等 leo 清單」表格(progress-guard routine 每日核實維護)。
|
||||
* - dash_task 的 scope:"today" 沒有日期——07-04 的「今日路線」到 07-07 還被當今天的。
|
||||
* - dash_beat 是唯一有活管線的 dash_*(progress-guard/cloud-worker/watchdog 每日寫入)。
|
||||
*
|
||||
* 整修後的資料源:
|
||||
* 等你的事 → 首選 Gitea sprint 檔等leo清單(需 GITEA_BASE_URL var + GITEA_TOKEN secret;
|
||||
* 進程內 fetch Gitea API,非 GitHub、無 D20 疑慮);讀不到 → fallback dash_wait
|
||||
* 但必標 age + stale 警示;連 dash_wait 都沒有 → 誠實顯示「管線未接」。
|
||||
* 今日路線 → dash_task,但以台北日曆日判 is_today;非今日寫入=降級顯示「最後路線(N 天前)」,
|
||||
* 不假裝是今天的。今日無寫入時明講管線缺口(sprint 任務板→dashboard 無自動投影)。
|
||||
* 系統狀況 → live 健康信號:KBDB /health、/embed/backfill/status(enabled:false 誠實顯示)、
|
||||
* kbdb-graph-plugin /triplets/stats、workflow 總數(KBDB entry_type=workflow)。
|
||||
* 總庫規模 → KBDB entries 總數/wiki_card 數/triplets 數,全部 live API 一手拉。
|
||||
*
|
||||
* 燈號判定(寫死在端點,頁面只渲染):
|
||||
* red = 任一 task status=blocked,或最新心跳距今 > 240 分(僅台北時間 09:00-22:00 判定;
|
||||
* 窗外心跳老是正常的睡眠狀態,不判)。
|
||||
* yellow = 無 red 條件,但有 blocked 以外的落後標記(task status 不在 done/doing/todo/blocked
|
||||
* 四個標準值內,如 late/behind——寫入方標了非標準狀態=落後訊號)。
|
||||
* red = 「今日寫入」的任務有 blocked,或最新心跳距今 > 240 分(台北 09:00-22:00 窗內判定),
|
||||
* 或 KBDB /health 打不通。stale 殘任務**不再**觸發燈號(07-04 的 blocked 不該讓 07-07 亮紅)。
|
||||
* yellow = 無 red 條件,但今日任務有非標準 status(late/behind 等落後標記)。
|
||||
* green = 其餘。
|
||||
*
|
||||
* 薄殼定位:這是「聚合端點」(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯,
|
||||
* 判定全在此端點;讀 KBDB 走 HTTP(kbdbBase,同 proxy 慣例),不新增 binding、不碰 D1/SQL。
|
||||
* 薄殼定位:聚合端點(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯;判定純函式抽在
|
||||
* lib/console-dashboard-model.ts(可單測)。讀 KBDB 走 HTTP(kbdbBase 慣例),不新增 binding。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbBase } from './kbdb-proxy';
|
||||
import { kbdbBase, graphBase } from './kbdb-proxy';
|
||||
import { validateConsoleSession } from './console-auth';
|
||||
import {
|
||||
type KbdbEntry,
|
||||
type WaitingItem,
|
||||
type WaitingModel,
|
||||
parseCreatedAtMs,
|
||||
parseJsonContent,
|
||||
agoMinutes,
|
||||
buildRouteModel,
|
||||
buildWaitingFallback,
|
||||
parseSprintWaitingTable,
|
||||
pickLatestSprintFiles,
|
||||
sortWaitingItems,
|
||||
} from '../lib/console-dashboard-model';
|
||||
|
||||
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -38,59 +59,124 @@ const JUDGE_START_HOUR = 9; // 台北時間,含
|
||||
const JUDGE_END_HOUR = 22; // 台北時間,不含
|
||||
const STANDARD_TASK_STATUS = new Set(['done', 'doing', 'todo', 'blocked']);
|
||||
|
||||
interface KbdbEntry {
|
||||
id: string;
|
||||
content: string | null;
|
||||
entry_type: string;
|
||||
created_at: string | number; // leo21c 實測是 epoch 秒(number);防禦性也吃 sqlite/ISO 字串
|
||||
}
|
||||
|
||||
/** created_at 實測(leo21c KBDB)=epoch 秒數 number;防禦性同時吃 epoch 毫秒與
|
||||
* 'YYYY-MM-DD HH:MM:SS'(UTC)/ ISO 字串(別的部署可能不同 schema 版本)。 */
|
||||
function parseCreatedAtMs(s: string | number | null | undefined): number | null {
|
||||
if (s === null || s === undefined || s === '') return null;
|
||||
if (typeof s === 'number') return s < 1e12 ? s * 1000 : s; // 秒 vs 毫秒
|
||||
if (/^\d+$/.test(s)) {
|
||||
const n = Number(s);
|
||||
return n < 1e12 ? n * 1000 : n;
|
||||
}
|
||||
const iso = /T/.test(s) ? s : `${s.replace(' ', 'T')}Z`;
|
||||
const ms = Date.parse(iso);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
function parseJsonContent(e: KbdbEntry): Record<string, unknown> | null {
|
||||
if (!e.content) return null;
|
||||
async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise<KbdbEntry[]> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
const params = new URLSearchParams({ owner_id: tenant, entry_type: entryType, limit: String(limit) });
|
||||
try {
|
||||
const v = JSON.parse(e.content);
|
||||
return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
|
||||
const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { entries?: KbdbEntry[] };
|
||||
return data.entries ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 泛用 GET JSON(失敗回 null,caller 誠實顯示「讀不到」,不編數字)。 */
|
||||
async function fetchJson<T>(url: string, headers?: Record<string, string>): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(url, headers ? { headers } : undefined);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise<KbdbEntry[]> {
|
||||
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
|
||||
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
const params = new URLSearchParams({ owner_id: tenant, entry_type: entryType, limit: String(limit) });
|
||||
const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { entries?: KbdbEntry[] };
|
||||
return data.entries ?? [];
|
||||
const params = new URLSearchParams({ ...filters, limit: '1' });
|
||||
const data = await fetchJson<{ total?: unknown }>(`${base}/entries?${params.toString()}`, headers);
|
||||
return data && typeof data.total === 'number' ? data.total : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「等你的事」活資料源:InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護)。
|
||||
* 需 GITEA_BASE_URL(var)+ GITEA_TOKEN(secret,建議唯讀 scope)。請求序:
|
||||
* 列目錄挑最新兩個 sprint-*.md(換 sprint 後前一檔常還有未銷案項,例:07b 開了、
|
||||
* 🔴 mira 憑證外洩仍掛 07a)→ 各抓 raw 解析合併 → 最新檔的最後 commit 時間當「清單維護於」。
|
||||
* 任一步失敗回 null → caller fallback dash_wait(標 age),不硬湊。
|
||||
*/
|
||||
async function fetchGiteaWaiting(env: Bindings, nowMs: number): Promise<WaitingModel | null> {
|
||||
const base = (env.GITEA_BASE_URL ?? '').replace(/\/$/, '');
|
||||
const token = env.GITEA_TOKEN;
|
||||
if (!base || !token) return null;
|
||||
const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
|
||||
const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
|
||||
const headers = { Authorization: `token ${token}` };
|
||||
try {
|
||||
const files = await fetchJson<{ name: string }[]>(`${base}/api/v1/repos/${repo}/contents/${encodeURI(dir)}`, headers);
|
||||
if (!files) return null;
|
||||
const sprints = pickLatestSprintFiles(files.map((f) => f.name));
|
||||
if (!sprints.length) return null;
|
||||
const parsed = await Promise.all(
|
||||
sprints.map(async (name) => {
|
||||
const rawRes = await fetch(`${base}/api/v1/repos/${repo}/raw/${encodeURI(`${dir}/${name}`)}`, { headers });
|
||||
if (!rawRes.ok) return null;
|
||||
return parseSprintWaitingTable(await rawRes.text(), name);
|
||||
}),
|
||||
);
|
||||
const readFiles = sprints.filter((_, i) => parsed[i] !== null);
|
||||
const merged = parsed.filter((p): p is WaitingItem[] => p !== null).flat();
|
||||
if (!readFiles.length) return null; // 全部解析失敗=誠實 fallback
|
||||
// 清單上次維護時間 = 現役 sprint 檔最後 commit(progress-guard 每日 commit,>48h 沒動才算 stale)
|
||||
let ago = -1;
|
||||
const commits = await fetchJson<{ commit?: { committer?: { date?: string } } }[]>(
|
||||
`${base}/api/v1/repos/${repo}/commits?path=${encodeURIComponent(`${dir}/${readFiles[0]}`)}&limit=1&stat=false&verification=false&files=false`,
|
||||
headers,
|
||||
);
|
||||
const date = commits?.[0]?.commit?.committer?.date;
|
||||
if (date) {
|
||||
const ms = Date.parse(date);
|
||||
if (!Number.isNaN(ms)) ago = agoMinutes(nowMs, ms);
|
||||
}
|
||||
return {
|
||||
items: sortWaitingItems(merged),
|
||||
source: 'gitea_sprint',
|
||||
updated_ago_minutes: ago,
|
||||
stale: ago >= 0 && ago > 48 * 60,
|
||||
sprint_files: readFiles,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
|
||||
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const now = Date.now();
|
||||
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
|
||||
const [beatEntries, taskEntries, waitEntries, inboxEntries] = await Promise.all([
|
||||
const [
|
||||
beatEntries,
|
||||
taskEntries,
|
||||
waitEntries,
|
||||
inboxEntries,
|
||||
giteaWaiting,
|
||||
kbdbHealth,
|
||||
embedStatus,
|
||||
graphStats,
|
||||
entriesTotal,
|
||||
wikiCardTotal,
|
||||
workflowTotal,
|
||||
] = await Promise.all([
|
||||
fetchEntries(c.env, tenant, 'dash_beat', 100),
|
||||
fetchEntries(c.env, tenant, 'dash_task', 200),
|
||||
fetchEntries(c.env, tenant, 'dash_wait', 100),
|
||||
fetchEntries(c.env, tenant, 'inbox', 200),
|
||||
fetchGiteaWaiting(c.env, now),
|
||||
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
|
||||
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
|
||||
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
|
||||
fetchEntryTotal(c.env, {}),
|
||||
fetchEntryTotal(c.env, { entry_type: 'wiki_card' }),
|
||||
fetchEntryTotal(c.env, { entry_type: 'workflow', owner_id: tenant }),
|
||||
]);
|
||||
|
||||
// dash_beat:每 actor 最新一筆(list 已 created_at DESC → first-seen 即最新)
|
||||
// dash_beat:每 actor 最新一筆(list 已 created_at DESC → first-seen 即最新)。唯一有活管線的 dash_*。
|
||||
const beats: { actor: string; event: string; note: string; at: string | number; ago_minutes: number }[] = [];
|
||||
const seenActors = new Set<string>();
|
||||
for (const e of beatEntries) {
|
||||
@@ -104,37 +190,26 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
event: typeof j?.event === 'string' ? (j.event as string) : '',
|
||||
note: typeof j?.note === 'string' ? (j.note as string) : '',
|
||||
at: e.created_at,
|
||||
ago_minutes: ms === null ? -1 : Math.max(0, Math.round((now - ms) / 60000)),
|
||||
ago_minutes: agoMinutes(now, ms),
|
||||
});
|
||||
}
|
||||
const lastBeat = beats.filter((b) => b.ago_minutes >= 0).sort((a, b) => a.ago_minutes - b.ago_minutes)[0] ?? null;
|
||||
|
||||
// dash_task:同 title 取最新(後寫蓋前寫)
|
||||
const taskByTitle = new Map<string, { title: string; status: string; order: number; scope: string }>();
|
||||
for (const e of taskEntries) {
|
||||
const j = parseJsonContent(e);
|
||||
const title = typeof j?.title === 'string' ? (j.title as string) : null;
|
||||
if (!title || taskByTitle.has(title)) continue;
|
||||
taskByTitle.set(title, {
|
||||
title,
|
||||
status: typeof j?.status === 'string' ? (j.status as string) : 'todo',
|
||||
order: typeof j?.order === 'number' ? (j.order as number) : 999,
|
||||
scope: j?.scope === 'week' ? 'week' : 'today',
|
||||
});
|
||||
}
|
||||
const tasks = [...taskByTitle.values()].sort((a, b) =>
|
||||
a.scope !== b.scope ? (a.scope === 'today' ? -1 : 1) : a.order - b.order,
|
||||
);
|
||||
// 今日路線:dash_task + 台北日曆日判定(純函式,見 lib/console-dashboard-model.ts)
|
||||
const route = buildRouteModel(taskEntries, now);
|
||||
|
||||
// dash_wait:同 title 取最新,只回 open
|
||||
const waitByTitle = new Map<string, string>();
|
||||
for (const e of waitEntries) {
|
||||
const j = parseJsonContent(e);
|
||||
const title = typeof j?.title === 'string' ? (j.title as string) : null;
|
||||
if (!title || waitByTitle.has(title)) continue;
|
||||
waitByTitle.set(title, typeof j?.status === 'string' ? (j.status as string) : 'open');
|
||||
// 等你的事:Gitea sprint 等leo清單優先;讀不到 fallback dash_wait(帶 age + stale)
|
||||
let waiting: WaitingModel;
|
||||
if (giteaWaiting) {
|
||||
waiting = giteaWaiting;
|
||||
} else {
|
||||
waiting = buildWaitingFallback(waitEntries, now);
|
||||
if (waiting.source === 'kbdb_dash_wait' && !(c.env.GITEA_BASE_URL && c.env.GITEA_TOKEN)) {
|
||||
waiting.note = 'Gitea sprint 清單未接(缺 GITEA_TOKEN secret)——以下是 dash_wait 殘資料';
|
||||
} else if (waiting.source === 'kbdb_dash_wait') {
|
||||
waiting.note = 'Gitea sprint 清單讀取失敗——以下是 dash_wait 殘資料';
|
||||
}
|
||||
}
|
||||
const waiting = [...waitByTitle.entries()].filter(([, s]) => s === 'open').map(([title]) => ({ title }));
|
||||
|
||||
// inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
|
||||
const inboxNew = inboxEntries.reduce((n, e) => {
|
||||
@@ -142,25 +217,69 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
return j && j.status !== 'done' ? n + 1 : n;
|
||||
}, 0);
|
||||
|
||||
// 燈號
|
||||
const hasBlocked = tasks.some((t) => t.status === 'blocked');
|
||||
const hasLagMark = tasks.some((t) => !STANDARD_TASK_STATUS.has(t.status));
|
||||
// 燈號:只吃「今日寫入」的任務 + 心跳 + KBDB 健康(stale 殘任務不再觸發燈號)
|
||||
const todayWrites = route.tasks.filter((t) => t.is_today_write);
|
||||
const hasBlocked = todayWrites.some((t) => t.status === 'blocked');
|
||||
const hasLagMark = todayWrites.some((t) => !STANDARD_TASK_STATUS.has(t.status));
|
||||
const taipeiHour = new Date(now + 8 * 3600 * 1000).getUTCHours();
|
||||
const inJudgeWindow = taipeiHour >= JUDGE_START_HOUR && taipeiHour < JUDGE_END_HOUR;
|
||||
const beatStale = lastBeat === null || lastBeat.ago_minutes > STALE_MINUTES;
|
||||
const kbdbOk = kbdbHealth?.ok === true;
|
||||
const light: 'green' | 'yellow' | 'red' =
|
||||
hasBlocked || (inJudgeWindow && beatStale) ? 'red' : hasLagMark ? 'yellow' : 'green';
|
||||
hasBlocked || (inJudgeWindow && beatStale) || !kbdbOk ? 'red' : hasLagMark ? 'yellow' : 'green';
|
||||
const lightReason = !kbdbOk
|
||||
? 'KBDB 基本盤 /health 打不通'
|
||||
: hasBlocked
|
||||
? '今日任務有 blocked'
|
||||
: inJudgeWindow && beatStale
|
||||
? `心跳超過 ${STALE_MINUTES} 分鐘`
|
||||
: hasLagMark
|
||||
? '今日任務有落後標記'
|
||||
: '';
|
||||
|
||||
const todayTasks = tasks.filter((t) => t.scope === 'today');
|
||||
return c.json({
|
||||
light,
|
||||
light_reason: lightReason,
|
||||
last_beat: lastBeat ? { actor: lastBeat.actor, ago_minutes: lastBeat.ago_minutes, event: lastBeat.event, note: lastBeat.note } : null,
|
||||
beats,
|
||||
tasks,
|
||||
today_done: todayTasks.filter((t) => t.status === 'done').length,
|
||||
today_total: todayTasks.length,
|
||||
waiting,
|
||||
// 路線:tasks 保留原欄位(console.ts 消費端相容),另帶 age 與 is_today_write
|
||||
tasks: route.tasks.map((t) => ({
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
order: t.order,
|
||||
scope: t.scope,
|
||||
age_minutes: t.age_minutes,
|
||||
is_today_write: t.is_today_write,
|
||||
})),
|
||||
route_meta: {
|
||||
source: 'kbdb_dash_task',
|
||||
is_today: route.is_today,
|
||||
updated_ago_minutes: route.updated_ago_minutes,
|
||||
},
|
||||
today_done: route.today_done,
|
||||
today_total: route.today_total,
|
||||
waiting: waiting.items,
|
||||
waiting_meta: {
|
||||
source: waiting.source,
|
||||
updated_ago_minutes: waiting.updated_ago_minutes,
|
||||
stale: waiting.stale,
|
||||
sprint_files: waiting.sprint_files ?? null,
|
||||
note: waiting.note ?? null,
|
||||
},
|
||||
inbox_new: inboxNew,
|
||||
system: {
|
||||
kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
|
||||
embed: embedStatus
|
||||
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
|
||||
: null,
|
||||
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
|
||||
workflow_total: workflowTotal,
|
||||
},
|
||||
kb: {
|
||||
entries_total: entriesTotal,
|
||||
wiki_card_total: wikiCardTotal,
|
||||
triplets_total: graphStats?.total ?? null,
|
||||
},
|
||||
generated_at: new Date(now).toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -256,6 +375,8 @@ function renderDashboardHtml(): string {
|
||||
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: var(--ok); letter-spacing: .08em; text-align: center; }
|
||||
.wait-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; margin-top: 8px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.3); font-size: 16px; line-height: 1.5; }
|
||||
.wait-item .dm { color: var(--amber); font-size: 17px; flex: none; }
|
||||
.wait-meta { margin-top: 10px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); line-height: 1.7; }
|
||||
.wait-meta .warn { color: var(--err); }
|
||||
.subhead { display: flex; justify-content: space-between; align-items: baseline; margin: 24px 0 10px; }
|
||||
.subhead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); }
|
||||
.subhead .m { font-size: 13px; color: rgba(var(--ink-rgb),.4); }
|
||||
@@ -269,6 +390,14 @@ function renderDashboardHtml(): string {
|
||||
ul.route li.todo { color: rgba(var(--ink-rgb),.6); }
|
||||
ul.route li.todo .ic { color: rgba(var(--ink-rgb),.35); }
|
||||
ul.route li.blocked .ic { color: var(--err); }
|
||||
ul.route.faded li { opacity: .55; }
|
||||
.sys { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.sys .row { display: flex; justify-content: space-between; align-items: baseline; padding: 10px 14px; border-radius: 10px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); font-size: 14.5px; }
|
||||
.sys .row .k { color: rgba(var(--ink-rgb),.6); }
|
||||
.sys .row .v { font-family: ui-monospace, Menlo, monospace; font-size: 14px; }
|
||||
.sys .ok { color: var(--ok); }
|
||||
.sys .bad { color: var(--err); }
|
||||
.sys .off { color: rgba(var(--ink-rgb),.5); }
|
||||
.muted { color: rgba(var(--ink-rgb),.45); font-size: 14px; }
|
||||
.err { color: var(--err); font-size: 14px; }
|
||||
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.8; }
|
||||
@@ -307,11 +436,14 @@ function renderDashboardHtml(): string {
|
||||
<div class="wait-box" id="wait-box">
|
||||
<div class="wait-head">等你的事</div>
|
||||
<div id="wait-body" class="wait-none">載入中…</div>
|
||||
<div class="wait-meta" id="wait-meta"></div>
|
||||
</div>
|
||||
<div class="subhead"><span class="t">今日路線</span><span class="m">狀態即時同步</span></div>
|
||||
<div class="subhead"><span class="t">今日路線</span><span class="m" id="route-m"></span></div>
|
||||
<ul class="route" id="today-list"><li class="todo"><span class="ic">○</span>載入中…</li></ul>
|
||||
<div class="subhead" id="week-head" style="display:none"><span class="t">本週</span></div>
|
||||
<ul class="route" id="week-list"></ul>
|
||||
<div class="subhead"><span class="t">系統狀況</span><span class="m">live 健康信號</span></div>
|
||||
<div class="sys" id="sys-list"><div class="row"><span class="k">載入中…</span></div></div>
|
||||
<div class="stamp" id="stamp">每 60 秒自動刷新</div>
|
||||
<a class="enter" href="/console">進入完整控制台 ›</a>
|
||||
</main>
|
||||
@@ -332,6 +464,12 @@ function renderDashboardHtml(): string {
|
||||
const ic = ICONS[t.status] || '●';
|
||||
return '<li class="' + cls + '"><span class="ic">' + ic + '</span><span>' + esc(t.title) + '</span></li>';
|
||||
}
|
||||
function humanAge(m) {
|
||||
if (m == null || m < 0) return '時間不明';
|
||||
if (m < 60) return m + ' 分鐘前';
|
||||
if (m < 2880) return Math.round(m / 60) + ' 小時前';
|
||||
return Math.round(m / 1440) + ' 天前';
|
||||
}
|
||||
const CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];
|
||||
function cnDay(n) { return n <= 10 ? CNUM[n] : (n < 20 ? '十' + (n % 10 ? CNUM[n % 10] : '') : CNUM[Math.floor(n / 10)] + '十' + (n % 10 ? CNUM[n % 10] : '')); }
|
||||
const now = new Date();
|
||||
@@ -350,6 +488,9 @@ function renderDashboardHtml(): string {
|
||||
const m = e && e.message ? String(e.message) : String(e);
|
||||
return /failed to fetch|load failed|networkerror|network request failed/i.test(m) ? '連線中斷' : m;
|
||||
}
|
||||
function sysRow(k, v, cls) {
|
||||
return '<div class="row"><span class="k">' + esc(k) + '</span><span class="v ' + cls + '">' + esc(v) + '</span></div>';
|
||||
}
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/console/dashboard-data');
|
||||
@@ -361,28 +502,76 @@ function renderDashboardHtml(): string {
|
||||
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
|
||||
$('orb-char').textContent = cfg.ch;
|
||||
$('orb-title').textContent = cfg.title;
|
||||
$('orb-sub').textContent = d.last_beat
|
||||
$('orb-sub').textContent = (d.last_beat
|
||||
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
|
||||
: '尚無心跳資料';
|
||||
: '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '(' + d.light_reason + ')' : '');
|
||||
const done = d.today_done || 0, total = d.today_total || 0;
|
||||
$('done-n').textContent = done; $('total-n').textContent = total;
|
||||
$('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
|
||||
$('inbox-n').textContent = d.inbox_new || 0;
|
||||
const wb = $('wait-box'), body = $('wait-body');
|
||||
// ── 等你的事:來源 + 維護時間攤開講,stale 一定警示 ──
|
||||
const wb = $('wait-box'), body = $('wait-body'), wmeta = $('wait-meta');
|
||||
const wm = d.waiting_meta || {};
|
||||
if (d.waiting && d.waiting.length) {
|
||||
wb.classList.add('has');
|
||||
body.className = '';
|
||||
body.innerHTML = d.waiting.map((w) => '<div class="wait-item"><span class="dm">◆</span><span>' + esc(w.title) + '</span></div>').join('');
|
||||
body.innerHTML = d.waiting.map((w) =>
|
||||
'<div class="wait-item"><span class="dm">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +
|
||||
(w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>').join('');
|
||||
} else {
|
||||
wb.classList.remove('has');
|
||||
body.className = 'wait-none';
|
||||
body.textContent = '無,你不用做任何事';
|
||||
body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';
|
||||
}
|
||||
let metaTxt = '';
|
||||
if (wm.source === 'gitea_sprint') {
|
||||
metaTxt = '來源:sprint 等leo清單(' + esc((wm.sprint_files || []).join('、')) + ')・清單維護於 ' + humanAge(wm.updated_ago_minutes);
|
||||
if (wm.stale) metaTxt += '<br><span class="warn">⚠ 清單超過 2 天沒維護,可能過時</span>';
|
||||
} else if (wm.source === 'kbdb_dash_wait') {
|
||||
metaTxt = '<span class="warn">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + humanAge(wm.updated_ago_minutes) + ',可能過時</span>';
|
||||
} else {
|
||||
metaTxt = '<span class="warn">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
|
||||
}
|
||||
wmeta.innerHTML = metaTxt;
|
||||
// ── 今日路線:非今日寫入=誠實降級「最後路線(N 天前)」,不假裝是今天的 ──
|
||||
const rm = d.route_meta || {};
|
||||
const today = (d.tasks || []).filter((t) => t.scope === 'today');
|
||||
const week = (d.tasks || []).filter((t) => t.scope === 'week');
|
||||
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
|
||||
if (rm.is_today) {
|
||||
$('route-m').textContent = '更新於 ' + humanAge(rm.updated_ago_minutes);
|
||||
$('today-list').className = 'route';
|
||||
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
|
||||
} else if (today.length) {
|
||||
$('route-m').textContent = '最後路線・' + humanAge(rm.updated_ago_minutes) + '寫入';
|
||||
$('today-list').className = 'route faded';
|
||||
$('today-list').innerHTML =
|
||||
'<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + humanAge(rm.updated_ago_minutes) +
|
||||
'的殘留路線(sprint 任務板→dashboard 投影管線未接,等leo清單#15 裁決中)</span></li>' + today.map(taskLine).join('');
|
||||
} else {
|
||||
$('route-m').textContent = '';
|
||||
$('today-list').className = 'route';
|
||||
$('today-list').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">無資料——dash_task 管線未接</span></li>';
|
||||
}
|
||||
$('week-head').style.display = week.length ? '' : 'none';
|
||||
$('week-list').innerHTML = week.map(taskLine).join('');
|
||||
// ── 系統狀況 + 總庫規模(全 live,讀不到就標讀不到)──
|
||||
const sys = d.system || {}, kb = d.kb || {};
|
||||
const rows = [];
|
||||
rows.push(sysRow('KBDB 基本盤', sys.kbdb_ok ? '● 正常' : '● 打不通', sys.kbdb_ok ? 'ok' : 'bad'));
|
||||
if (sys.embed) {
|
||||
rows.push(sys.embed.enabled
|
||||
? sysRow('語意嵌入', '● 啟用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'ok')
|
||||
: sysRow('語意嵌入', '○ 停用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'bad'));
|
||||
} else {
|
||||
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
|
||||
}
|
||||
rows.push(sys.graph && sys.graph.ok
|
||||
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
|
||||
: sysRow('知識圖譜', '● 打不通', 'bad'));
|
||||
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
|
||||
rows.push(sysRow('總庫規模', (kb.entries_total == null ? '讀不到' : kb.entries_total.toLocaleString() + ' 筆') +
|
||||
'・wiki 卡 ' + (kb.wiki_card_total == null ? '?' : kb.wiki_card_total), kb.entries_total == null ? 'off' : ''));
|
||||
$('sys-list').innerHTML = rows.join('');
|
||||
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false })) + '<br>此頁不含機敏內容,免登入';
|
||||
} catch (e) {
|
||||
$('orb-char').textContent = '?';
|
||||
|
||||
@@ -326,6 +326,7 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
<div class="wait-box" id="ck-waitbox">
|
||||
<div class="wait-head">等你的事</div>
|
||||
<div id="ck-wait" class="wait-none">載入中…</div>
|
||||
<div id="ck-waitmeta" style="margin-top:10px;text-align:center;font-size:12.5px;color:rgba(var(--ink-rgb),.45);line-height:1.7"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -699,6 +700,12 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
var cls = TASK_ICONS[t.status] ? t.status : 'blocked';
|
||||
return '<li class="' + cls + '"><span class="ic">' + (TASK_ICONS[t.status] || '●') + '</span><span>' + esc(t.title) + '</span></li>';
|
||||
}
|
||||
function ckAge(m) {
|
||||
if (m == null || m < 0) return '時間不明';
|
||||
if (m < 60) return m + ' 分鐘前';
|
||||
if (m < 2880) return Math.round(m / 60) + ' 小時前';
|
||||
return Math.round(m / 1440) + ' 天前';
|
||||
}
|
||||
function loadCockpit() {
|
||||
var now = new Date();
|
||||
$('ck-date').textContent = CNUM[now.getMonth() + 1] + '月' + cnDay(now.getDate()) + '日';
|
||||
@@ -711,24 +718,46 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
|
||||
$('ck-char').textContent = cfg.ch;
|
||||
$('ck-title').textContent = cfg.title;
|
||||
$('ck-sub').textContent = d.last_beat
|
||||
$('ck-sub').textContent = (d.last_beat
|
||||
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
|
||||
: '尚無心跳資料';
|
||||
: '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '(' + d.light_reason + ')' : '');
|
||||
var done = d.today_done || 0, total = d.today_total || 0;
|
||||
$('ck-done').textContent = done; $('ck-total').textContent = total;
|
||||
$('ck-bar').style.width = (total ? Math.round(done / total * 100) : 0) + '%';
|
||||
$('ck-inbox').textContent = d.inbox_new || 0;
|
||||
var wb = $('ck-waitbox'), body = $('ck-wait');
|
||||
// 等你的事:來源與維護時間攤開講(fix/console-dashboard-live-data)——
|
||||
// Gitea sprint 等leo清單是活資料源;fallback dash_wait 一定標 stale,不假裝是今天的
|
||||
var wb = $('ck-waitbox'), body = $('ck-wait'), wmeta = $('ck-waitmeta');
|
||||
var wm = d.waiting_meta || {};
|
||||
if (d.waiting && d.waiting.length) {
|
||||
wb.classList.add('has'); body.className = '';
|
||||
body.innerHTML = d.waiting.map(function (w) { return '<div class="wait-item"><span class="dm">◆</span><span>' + esc(w.title) + '</span></div>'; }).join('');
|
||||
body.innerHTML = d.waiting.map(function (w) {
|
||||
return '<div class="wait-item"><span class="dm">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +
|
||||
(w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>';
|
||||
}).join('');
|
||||
} else {
|
||||
wb.classList.remove('has'); body.className = 'wait-none';
|
||||
body.textContent = '無,你不用做任何事';
|
||||
body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';
|
||||
}
|
||||
if (wm.source === 'gitea_sprint') {
|
||||
wmeta.innerHTML = '來源:sprint 等leo清單・維護於 ' + ckAge(wm.updated_ago_minutes) +
|
||||
(wm.stale ? '<br><span class="err">⚠ 清單超過 2 天沒維護,可能過時</span>' : '');
|
||||
} else if (wm.source === 'kbdb_dash_wait') {
|
||||
wmeta.innerHTML = '<span class="err">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + ckAge(wm.updated_ago_minutes) + ',可能過時</span>';
|
||||
} else if (wm.source === 'none') {
|
||||
wmeta.innerHTML = '<span class="err">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
|
||||
} else {
|
||||
wmeta.textContent = '';
|
||||
}
|
||||
// 今日路線:非今日寫入=誠實降級為「最後路線(N 天前)」,不假裝是今天的
|
||||
var rm = d.route_meta || {};
|
||||
var today = (d.tasks || []).filter(function (t) { return t.scope === 'today'; });
|
||||
var week = (d.tasks || []).filter(function (t) { return t.scope === 'week'; });
|
||||
$('ck-today').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
|
||||
if (rm.is_today || !today.length) {
|
||||
$('ck-today').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目——dash_task 今天沒有寫入</span></li>';
|
||||
} else {
|
||||
$('ck-today').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + ckAge(rm.updated_ago_minutes) + '的殘留路線</span></li>' + today.map(taskLine).join('');
|
||||
}
|
||||
$('ck-weekhead').style.display = week.length ? '' : 'none';
|
||||
$('ck-week').innerHTML = week.map(taskLine).join('');
|
||||
$('ck-stamp').textContent = '每 60 秒自動刷新・上次 ' + new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false });
|
||||
|
||||
@@ -195,7 +195,7 @@ kbdbProxyRouter.get('/kbdb/entries/:id', async (c) => {
|
||||
// 回 { node, edges[], neighbors[], edgeCount, neighborCount })。瀏覽器不能持 KBDB_INTERNAL_TOKEN,
|
||||
// 故經 cypher 代轉(token 只在 server 側)。plugin base 現算慣例同 registry/KBDB_BASE_URL。
|
||||
|
||||
function graphBase(env: Bindings): string {
|
||||
export function graphBase(env: Bindings): string {
|
||||
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, '');
|
||||
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,15 @@ export type Bindings = {
|
||||
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
|
||||
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
|
||||
CONSOLE_TENANT?: string;
|
||||
// ── console 駕駛艙「等你的事」活資料源(fix/console-dashboard-live-data,2026-07-07)──
|
||||
// 等 leo 的事真相源=InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護),
|
||||
// 不是沒人維護的 KBDB dash_wait。cypher 進程內 fetch Gitea API(自架 Gitea,非 GitHub,
|
||||
// 無 D20 頻率疑慮)。缺 GITEA_BASE_URL 或 GITEA_TOKEN → 該區塊 fallback dash_wait
|
||||
// (頁面誠實標 age + stale),不假綠。
|
||||
GITEA_BASE_URL?: string; // wrangler.toml [vars],如 https://git.uncle6.me
|
||||
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token)
|
||||
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
|
||||
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
|
||||
// kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算
|
||||
// https://kbdb-graph-plugin.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
|
||||
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* console 駕駛艙聚合純函式測試(fix/console-dashboard-live-data,2026-07-07)
|
||||
*
|
||||
* 針對 leo 抱怨「等你的事錯了好幾天」的三個 stale 根因逐一驗證:
|
||||
* 1. dash_task 的 scope:"today" 沒日期 → 幾天前的任務不得再算「今日」
|
||||
* 2. dash_wait 殘資料 fallback 必標 stale
|
||||
* 3. sprint 檔「等 leo 清單」表格解析:已銷案(~~/✅/已完成)要濾掉、🔴 排最前
|
||||
*
|
||||
* 樣本資料直接取自 leo21c live KBDB 與 InkStoneCo sprint-2026-07a.md 的真實形態
|
||||
* (epoch 秒 created_at、07-04 殘 entries、含劃掉列的 markdown 表格),非憑空編造。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseCreatedAtMs,
|
||||
taipeiDayKey,
|
||||
buildRouteModel,
|
||||
buildWaitingFallback,
|
||||
parseSprintWaitingTable,
|
||||
sortWaitingItems,
|
||||
cleanWaitingTitle,
|
||||
pickLatestSprintFiles,
|
||||
humanAge,
|
||||
type KbdbEntry,
|
||||
} from '../src/lib/console-dashboard-model';
|
||||
|
||||
// 2026-07-07 10:00 UTC(台北 18:00)當「現在」
|
||||
const NOW = Date.parse('2026-07-07T10:00:00Z');
|
||||
const SEC = (iso: string) => Math.floor(Date.parse(iso) / 1000);
|
||||
|
||||
function entry(created: string, content: unknown): KbdbEntry {
|
||||
return { id: crypto.randomUUID(), entry_type: 'x', content: JSON.stringify(content), created_at: SEC(created) };
|
||||
}
|
||||
|
||||
describe('parseCreatedAtMs / taipeiDayKey', () => {
|
||||
it('吃 epoch 秒(leo21c 實測形態)、epoch 毫秒、ISO、sqlite 字串', () => {
|
||||
expect(parseCreatedAtMs(1783413742)).toBe(1783413742000);
|
||||
expect(parseCreatedAtMs(1783413742000)).toBe(1783413742000);
|
||||
expect(parseCreatedAtMs('2026-07-07T08:42:22Z')).toBe(Date.parse('2026-07-07T08:42:22Z'));
|
||||
expect(parseCreatedAtMs('2026-07-07 08:42:22')).toBe(Date.parse('2026-07-07T08:42:22Z'));
|
||||
expect(parseCreatedAtMs(null)).toBeNull();
|
||||
});
|
||||
it('台北日曆日:UTC 20:00 已是台北隔天', () => {
|
||||
expect(taipeiDayKey(Date.parse('2026-07-06T20:00:00Z'))).toBe('2026-07-07');
|
||||
expect(taipeiDayKey(Date.parse('2026-07-06T15:00:00Z'))).toBe('2026-07-06');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRouteModel — 今日路線不吃殘資料(stale 根因 2)', () => {
|
||||
it('07-04 寫入的 scope:today 任務,07-07 看不再算今日(is_today=false、today_total=0)', () => {
|
||||
// live 實態:dash_task 最後寫入 2026-07-04,07-07 dashboard 仍把它當「今日 2/4」
|
||||
const entries = [
|
||||
entry('2026-07-04T10:22:00Z', { title: 'T-loop4 檢討 routine 首跑', status: 'done', order: 1, scope: 'today' }),
|
||||
entry('2026-07-04T04:44:00Z', { title: 'T-cockpit 駕駛艙三件套', status: 'doing', order: 2, scope: 'today' }),
|
||||
entry('2026-07-04T04:44:00Z', { title: 'T-kb-skeleton 總庫 0→1', status: 'todo', order: 1, scope: 'week' }),
|
||||
];
|
||||
const m = buildRouteModel(entries, NOW);
|
||||
expect(m.is_today).toBe(false);
|
||||
expect(m.today_total).toBe(0); // 不假裝 07-04 的路線是今天的
|
||||
expect(m.today_done).toBe(0);
|
||||
expect(m.tasks).toHaveLength(3); // 資料仍在(頁面降級顯示「最後路線(N 天前)」)
|
||||
expect(m.tasks.every((t) => !t.is_today_write)).toBe(true);
|
||||
expect(m.updated_ago_minutes).toBeGreaterThan(3 * 24 * 60 - 60); // ≈3 天
|
||||
});
|
||||
it('今天寫入的任務照常計入今日(同 title 後寫蓋前寫)', () => {
|
||||
const entries = [
|
||||
entry('2026-07-07T09:00:00Z', { title: 'A', status: 'done', order: 1, scope: 'today' }), // 最新(DESC first)
|
||||
entry('2026-07-07T08:00:00Z', { title: 'B', status: 'doing', order: 2, scope: 'today' }),
|
||||
entry('2026-07-04T04:44:00Z', { title: 'A', status: 'todo', order: 1, scope: 'today' }), // 舊值被蓋
|
||||
];
|
||||
const m = buildRouteModel(entries, NOW);
|
||||
expect(m.is_today).toBe(true);
|
||||
expect(m.today_total).toBe(2);
|
||||
expect(m.today_done).toBe(1);
|
||||
expect(m.tasks.find((t) => t.title === 'A')?.status).toBe('done');
|
||||
});
|
||||
it('空庫:誠實回 -1 / false,不編資料', () => {
|
||||
const m = buildRouteModel([], NOW);
|
||||
expect(m.tasks).toHaveLength(0);
|
||||
expect(m.is_today).toBe(false);
|
||||
expect(m.updated_ago_minutes).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWaitingFallback — dash_wait 殘資料必標 stale(stale 根因 1)', () => {
|
||||
it('07-04 的 open 項在 07-07 仍列出,但 stale=true、age 正確', () => {
|
||||
// live 實態:等leo清單#11 已於 07-05 銷案(誤判),dash_wait 卻沒人寫 closed
|
||||
const entries = [
|
||||
entry('2026-07-04T11:05:00Z', { title: '檢查 Claude Environment 兩個 routine trigger', status: 'open' }),
|
||||
entry('2026-07-04T10:41:00Z', { title: 'CF token 加 Vectorize:Edit 權限', status: 'closed' }),
|
||||
entry('2026-07-04T05:56:00Z', { title: 'CF token 加 Vectorize:Edit 權限', status: 'open' }), // 舊 open 被 closed 蓋
|
||||
];
|
||||
const m = buildWaitingFallback(entries, NOW);
|
||||
expect(m.source).toBe('kbdb_dash_wait');
|
||||
expect(m.items.map((i) => i.title)).toEqual(['檢查 Claude Environment 兩個 routine trigger']);
|
||||
expect(m.stale).toBe(true); // >24h 沒維護 → 頁面必須警示「可能過時」
|
||||
expect(m.updated_ago_minutes).toBeGreaterThan(24 * 60);
|
||||
});
|
||||
it('完全無資料 → source none + 管線未接 note', () => {
|
||||
const m = buildWaitingFallback([], NOW);
|
||||
expect(m.source).toBe('none');
|
||||
expect(m.items).toHaveLength(0);
|
||||
expect(m.note).toContain('管線未接');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSprintWaitingTable — 等leo清單活資料源(stale 根因 3 的解)', () => {
|
||||
// 取自 InkStoneCo sprint-2026-07a.md 真實形態(截短),含:劃掉列、✅ 急迫欄、🔴/🟡/⚪
|
||||
const MD = `# Sprint 2026-07a
|
||||
## 任務板
|
||||
- [x] 東西
|
||||
|
||||
## 等 leo 清單(暗待辦歸零區)
|
||||
|
||||
| # | 事項 | 急迫 |
|
||||
|---|------|------|
|
||||
| 1 | ~~Claude Environment 補 secrets~~ ✅ 07-02 實測確認 | ⚪ 已完成 |
|
||||
| 3 | Telegram bot token rotate(可與 #1 一起做) | 🟡 建議今天順手 |
|
||||
| 9 | **credential 路徑1 不成立,SDD 需重新設計**(詳情見 Arcrun#2) | ✅ 已解,T2-T9 解凍 |
|
||||
| 11 | ~~cloud-worker trigger 連續兩天疑似未觸發~~ **✅ 銷案=誤判** | ✅ 銷案(誤判)+防複發已上線 |
|
||||
| 14 | **\`NPM_API_TOKEN\` 缺失,acr search 等 CLI 改動全數卡在 npm 未發版**(2026-07-05 核實):Environment 現只有五把 | 🟡 建議本週補 |
|
||||
| 13 | 🔴 **mira \`.env\` 歷史憑證外洩(2026-07-05 發現,已止血未 rotate)**:mira repo 曾把 .env 誤入 git 追蹤 | 🔴 需 leo 裁(安全事件) |
|
||||
| 15 | **T-cockpit③(md→Gitea Project 投影)方向選擇**(2026-07-06 spike 核實):需 leo 三選一 | 🟡 品味/方向題 |
|
||||
|
||||
### 下一段
|
||||
別的內容`;
|
||||
|
||||
it('已銷案列(~~ 或急迫欄 ✅/已完成)全濾掉;open 項留下、🔴 排最前', () => {
|
||||
const items = parseSprintWaitingTable(MD, 'sprint-2026-07a.md')!;
|
||||
expect(items).not.toBeNull();
|
||||
expect(items.map((i) => i.id)).toEqual(['13', '3', '14', '15']); // 🔴 最前,餘依表序
|
||||
expect(items[0].urgency).toBe('🔴');
|
||||
expect(items[0].title).toContain('mira');
|
||||
expect(items[0].sprint).toBe('sprint-2026-07a.md');
|
||||
expect(items.every((i) => !i.title.includes('~~') && !i.title.includes('**'))).toBe(true);
|
||||
});
|
||||
it('07b 形態:段落標題帶註記「(RAG 線)」、編號帶字母前綴 R1', () => {
|
||||
const md07b = `## 等 leo 清單(RAG 線)
|
||||
|
||||
| # | 事項 | 急迫 |
|
||||
|---|------|------|
|
||||
| R1 | pilot 客戶挑選(1–2 家)+產品命名/定價區間(rag-wave1 T0) | 🟡 Phase 0 前置 |
|
||||
| R2 | ~~Gemini key~~ ✅ 已補 | ⚪ 已完成 |
|
||||
`;
|
||||
const items = parseSprintWaitingTable(md07b, 'sprint-2026-07b.md')!;
|
||||
expect(items.map((i) => i.id)).toEqual(['R1']);
|
||||
expect(items[0].urgency).toBe('🟡');
|
||||
});
|
||||
it('跨 sprint 合併排序:現役檔項目先傳,🔴 仍浮最上', () => {
|
||||
const b = parseSprintWaitingTable('## 等 leo 清單\n| # | 事項 | 急迫 |\n|---|---|---|\n| R1 | RAG pilot | 🟡 x |', 'b.md')!;
|
||||
const a = parseSprintWaitingTable('## 等 leo 清單\n| # | 事項 | 急迫 |\n|---|---|---|\n| 13 | 憑證外洩 | 🔴 x |', 'a.md')!;
|
||||
const merged = sortWaitingItems([...b, ...a]);
|
||||
expect(merged.map((i) => i.id)).toEqual(['13', 'R1']);
|
||||
});
|
||||
it('沒有等 leo 段落 → null(caller 誠實 fallback,不硬湊)', () => {
|
||||
expect(parseSprintWaitingTable('# 別的檔\n沒有清單')).toBeNull();
|
||||
expect(parseSprintWaitingTable('## 等 leo 清單\n(還沒建表)')).toBeNull();
|
||||
});
|
||||
it('cleanWaitingTitle:去粗體、長標題在「:」截斷、80 字上限', () => {
|
||||
expect(cleanWaitingTitle('**NPM_API_TOKEN 缺失,CLI 卡在 npm 未發版**(07-05 核實):Environment 現只有五把')).toBe(
|
||||
'NPM_API_TOKEN 缺失,CLI 卡在 npm 未發版(07-05 核實)',
|
||||
);
|
||||
expect(cleanWaitingTitle('短標題').length).toBeLessThan(80);
|
||||
});
|
||||
it('cleanWaitingTitle:去開頭急迫 emoji(急迫欄已有)、截斷後不留未閉合括號', () => {
|
||||
// 等leo清單#13 實際形態:事項欄自帶 🔴 開頭
|
||||
expect(cleanWaitingTitle('🔴 **mira `.env` 歷史憑證外洩(2026-07-05 發現)**:mira repo 曾把 .env 誤入 git')).toBe(
|
||||
'mira .env 歷史憑證外洩(2026-07-05 發現)',
|
||||
);
|
||||
// 等leo清單#3 實際形態:「:」在括號內 → 截斷會留半個「(」,要剪掉
|
||||
expect(cleanWaitingTitle('Telegram bot token rotate(可與 #1 一起做:rotate 後直接填新值進 Environment)')).toBe(
|
||||
'Telegram bot token rotate',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickLatestSprintFiles / humanAge', () => {
|
||||
it('sprint-*.md 字典序取最新兩個(新→舊);無 sprint 檔回空陣列', () => {
|
||||
// live 實態(2026-07-07):07b 已開、07a 的 🔴 未銷案項仍要讀 → 兩檔都拿
|
||||
expect(pickLatestSprintFiles(['sprint-2026-07a.md', 'sprint-2026-07b.md', 'tasks.md', 'routines'])).toEqual([
|
||||
'sprint-2026-07b.md',
|
||||
'sprint-2026-07a.md',
|
||||
]);
|
||||
expect(pickLatestSprintFiles(['sprint-2026-06b.md', 'sprint-2026-07a.md', 'sprint-2026-05a.md'])).toEqual([
|
||||
'sprint-2026-07a.md',
|
||||
'sprint-2026-06b.md',
|
||||
]);
|
||||
expect(pickLatestSprintFiles(['tasks.md'])).toEqual([]);
|
||||
});
|
||||
it('humanAge 誠實顯示天級 age', () => {
|
||||
expect(humanAge(-1)).toBe('時間不明');
|
||||
expect(humanAge(30)).toBe('30 分鐘前');
|
||||
expect(humanAge(3 * 24 * 60)).toBe('3 天前');
|
||||
});
|
||||
});
|
||||
@@ -138,6 +138,15 @@ KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev"
|
||||
# (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。
|
||||
CONSOLE_TENANT = "leo"
|
||||
|
||||
# 駕駛艙「等你的事」活資料源(fix/console-dashboard-live-data,2026-07-07):
|
||||
# InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護)——KBDB dash_wait 沒有
|
||||
# 活的維護管線(07-04 之後沒人寫、銷案不同步),不再當首選資料源。
|
||||
# ⚠ 還需 `wrangler secret put GITEA_TOKEN`(建議唯讀 scope)才會啟用;缺 token → dashboard
|
||||
# 「等你的事」fallback dash_wait 並誠實標 age/stale。Self-hosted fork:清空 GITEA_BASE_URL 即整段停用。
|
||||
GITEA_BASE_URL = "https://git.uncle6.me"
|
||||
GITEA_SPRINT_REPO = "Leo/InkStoneCo"
|
||||
GITEA_SPRINT_DIR = "system-dev/docs/3-specs/autonomy-dispatch"
|
||||
|
||||
[[routes]]
|
||||
pattern = "cypher.arcrun.dev/*"
|
||||
zone_name = "arcrun.dev"
|
||||
|
||||
Reference in New Issue
Block a user