feat(console): 收件夾改裝=全部待辦分流台(Arcrun#9,kb-ingest SDD R7 消費端)

console 頁 7 從 Telegram 訊息清單改裝成三欄分流台:
- 資料源二合一:entry_type=todo(Logseq 萃取,Arcrun#8 ingest 線)+ entry_type=inbox
  (Telegram),/console/inbox-data → /console/triage-data(唯一消費者是本頁,一起改
  裝不留死端點;仍鎖 console session——待辦原文屬機敏)
- 三欄 80/15/5:ai=AI 會幫我搞定/collab=協作/leo=非我不可;owner_tier 缺席或看
  不懂(triage engine 還沒判)→ 歸 collab 欄+「未分流」徽章,不冒領 ai、不誤判 leo
- 各項顯示 text/project 徽章/source/marker;status:done 預設隱藏可切換
- per-project chips 過濾(全部/各 project/未分項),前端同包資料過濾零額外請求
- 手機直排沿 console 單檔薄殼;讀 KBDB 走既有 kbdbBase HTTP 慣例,不新增 binding

契約解析/分欄/計數=純函式 lib/console-triage-model.ts(比照 console-dashboard-model
模式,route 只做 IO),tests/console-triage-model.test.ts 11 測全綠(紅→綠驗證:抽掉
實作模組同測試即紅)。全套 vitest 98 passed,唯一失敗為 pre-existing executor
「不存在的零件」訊息斷言(main 同紅,與本 PR 無關)。

[總管-subagent]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 22:44:54 +08:00
parent 2fa0974e1c
commit 5cd790c94a
5 changed files with 377 additions and 64 deletions
@@ -0,0 +1,132 @@
/**
* console「分流台」純函式層(Arcrun#9 收件夾改裝=全部待辦分流台,2026-07-08 總管派工;
* 頂層 SDD kb-ingest-architecture R7 消費端)
*
* 為什麼獨立成 lib(比照 console-dashboard-model.ts):route 只做 IOfetch KBDB entries),
* 「兩種 entry 契約 → 統一分流項」的解析/分欄/分項判定全在這裡,vitest 直接餵資料驗證。
*
* 資料來源二合一(R7):
* - entry_type=todoLogseq 萃取灌入(Arcrun#8 ingest 線),content JSON 契約(已定)=
* {"text","marker","owner_tier":"ai|collab|leo","project","source","status":"new|done"}
* - entry_type=inboxTelegram 指令(inbox_receive),沿用 {"text","from","status"}
*
* 三欄分流(80/15/5,北極星「減 leo 負」):
* ai = AI 會幫我搞定(80%
* collab = AI 要我做完它接手(15%
* leo = 非 leo 不可(5%
* 無 owner_tier / 看不懂的值(triage engine 還沒判到)→ 歸 collab 欄但標 unclassified
* UI 顯「未分流」徽章——不冒領「AI 會搞定」、也不誤判成「非 leo 不可」,且欄位維持三欄
* 不另開第四欄(80/15/5 的故事不破)。issue 原文的 mira80/collab15/leo5 詞彙當同義吃進來
* ingest 端若照 issue 舊詞灌,UI 不至於整欄蒸發)。
*/
import { type KbdbEntry, parseCreatedAtMs, parseJsonContent } from './console-dashboard-model';
export type TriageTier = 'ai' | 'collab' | 'leo';
/** 契約值 → 三欄。issue 原文舊詞(mira80/collab15/leo5)當同義收;其餘=未分流。 */
const TIER_ALIASES: Record<string, TriageTier> = {
ai: 'ai',
collab: 'collab',
leo: 'leo',
mira80: 'ai',
collab15: 'collab',
leo5: 'leo',
};
export interface TriageItem {
id: string;
text: string;
tier: TriageTier;
/** trueowner_tier 缺席/看不懂(triage engine 未判),暫歸 collab 欄+UI 標「未分流」 */
unclassified: boolean;
/** '' = 未分項(UI 過濾 chips 用「未分項」承接) */
project: string;
/** todo 來源=content.source(如 notes);inbox 一律 telegram(可帶 from */
source: string;
/** Logseq markerTODO/DOING/LATER/NOW…);inbox 無='' */
marker: string;
status: 'new' | 'done';
origin: 'todo' | 'inbox';
at: string | number;
at_ms: number | null;
}
export interface TriageModel {
/** 全部項(created_at 新→舊);分欄/過濾在 UI 端做(資料一次給齊,chips 切換零請求) */
items: TriageItem[];
/** 出現過的 project(去重、字典序;不含 ''——「未分項」由 UI 依 items 判斷要不要出 chip */
projects: string[];
/** 各欄未完成數(status=new)+整體計數,供欄位標頭/收件計數用 */
counts: { ai: number; collab: number; leo: number; unclassified: number; done: number; total: number };
}
function normalizeStatus(v: unknown): 'new' | 'done' {
return v === 'done' ? 'done' : 'new';
}
function normalizeTier(v: unknown): { tier: TriageTier; unclassified: boolean } {
const mapped = typeof v === 'string' ? TIER_ALIASES[v.trim()] : undefined;
return mapped ? { tier: mapped, unclassified: false } : { tier: 'collab', unclassified: true };
}
function str(v: unknown): string {
return typeof v === 'string' ? v.trim() : '';
}
/** todo entry → 分流項。content 非 JSON(不合契約的殘資料)→ 原文當 text 誠實顯示,不丟棄。 */
export function todoToTriageItem(e: KbdbEntry): TriageItem {
const j = parseJsonContent(e);
const { tier, unclassified } = normalizeTier(j?.owner_tier);
return {
id: e.id,
text: str(j?.text) || (e.content ?? ''),
tier,
unclassified,
project: str(j?.project),
source: str(j?.source),
marker: str(j?.marker),
status: normalizeStatus(j?.status),
origin: 'todo',
at: e.created_at,
at_ms: parseCreatedAtMs(e.created_at),
};
}
/** inbox entry → 分流項(沿用 {"text","from","status"} 契約)。無 tier =未分流歸 collab。 */
export function inboxToTriageItem(e: KbdbEntry): TriageItem {
const j = parseJsonContent(e);
const from = str(j?.from);
return {
id: e.id,
text: str(j?.text) || (e.content ?? ''),
tier: 'collab',
unclassified: true,
project: '',
source: from ? `telegram・${from}` : 'telegram',
marker: '',
status: normalizeStatus(j?.status),
origin: 'inbox',
at: e.created_at,
at_ms: parseCreatedAtMs(e.created_at),
};
}
/** 二源合一 → 分流台模型。排序=created_at 新→舊(時間不明沉底);分欄計數只算未完成。 */
export function buildTriageModel(todoEntries: KbdbEntry[], inboxEntries: KbdbEntry[]): TriageModel {
const items = [
...todoEntries.map(todoToTriageItem),
...inboxEntries.map(inboxToTriageItem),
].sort((a, b) => (b.at_ms ?? -1) - (a.at_ms ?? -1));
const projects = [...new Set(items.map((i) => i.project).filter(Boolean))].sort();
const counts = { ai: 0, collab: 0, leo: 0, unclassified: 0, done: 0, total: items.length };
for (const it of items) {
if (it.status === 'done') {
counts.done++;
continue;
}
counts[it.tier]++;
if (it.unclassified) counts.unclassified++;
}
return { items, projects, counts };
}