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:
2026-07-07 11:02:22 +00:00
parent 29ba85e848
commit 99da35a885
7 changed files with 790 additions and 101 deletions
@@ -0,0 +1,259 @@
/**
* console 駕駛艙 dashboard 的聚合純函式層(2026-07-07 fix/console-dashboard-live-data
*
* 為什麼獨立成 libroutes/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" 字母前綴)
* - 事項欄以 ~~ 開頭(整項劃掉)→ 已結案,跳過
* - 急迫欄含 ✅/已完成/已解/銷案 → 已結案,跳過
* 解析失敗(找不到段落/表格)→ 回 nullcaller 誠實 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();
}
/** fallbackdash_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))} 天前`;
}