@@ -0,0 +1,270 @@
/**
* arcrun console 駕駛艙 dashboard( T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工)
*
* 兩個端點,皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
* - 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 刷新。無互動功能,就是儀表板。
*
* 資料契約(寫入方=總管/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')。
*
* 燈號判定(寫死在端點,頁面只渲染):
* red = 任一 task status=blocked,或最新心跳距今 > 240 分(僅台北時間 09:00-22:00 判定;
* 窗外心跳老是正常的睡眠狀態,不判)。
* yellow = 無 red 條件,但有 blocked 以外的落後標記(task status 不在 done/doing/todo/blocked
* 四個標準值內,如 late/behind——寫入方標了非標準狀態=落後訊號)。
* green = 其餘。
*
* 薄殼定位:這是「聚合端點」(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯,
* 判定全在此端點;讀 KBDB 走 HTTP( kbdbBase,同 proxy 慣例),不新增 binding、不碰 D1/SQL。
*/
import { Hono } from 'hono' ;
import type { Bindings } from '../types' ;
import { kbdbBase } from './kbdb-proxy' ;
export const consoleDashboardRouter = new Hono < { Bindings : Bindings } > ( ) ;
const STALE_MINUTES = 240 ;
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 < 1 e12 ? s * 1000 : s ; // 秒 vs 毫秒
if ( /^\d+$/ . test ( s ) ) {
const n = Number ( s ) ;
return n < 1 e12 ? 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 ;
try {
const v = JSON . parse ( e . content ) ;
return v && typeof v === 'object' ? ( v as Record < string , unknown > ) : null ;
} catch {
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 ) } ) ;
const res = await fetch ( ` ${ base } /entries? ${ params . toString ( ) } ` , { headers } ) ;
if ( ! res . ok ) return [ ] ;
const data = ( await res . json ( ) ) as { entries? : KbdbEntry [ ] } ;
return data . entries ? ? [ ] ;
}
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
consoleDashboardRouter . get ( '/console/dashboard-data' , async ( c ) = > {
const tenant = c . env . CONSOLE_TENANT || 'leo' ;
const now = Date . now ( ) ;
const [ beatEntries , taskEntries , waitEntries , inboxEntries ] = 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 ) ,
] ) ;
// dash_beat:每 actor 最新一筆(list 已 created_at DESC → first-seen 即最新)
const beats : { actor : string ; event : string ; note : string ; at : string | number ; ago_minutes : number } [ ] = [ ] ;
const seenActors = new Set < string > ( ) ;
for ( const e of beatEntries ) {
const j = parseJsonContent ( e ) ;
const actor = typeof j ? . actor === 'string' ? j.actor : null ;
if ( ! actor || seenActors . has ( actor ) ) continue ;
seenActors . add ( actor ) ;
const ms = parseCreatedAtMs ( e . created_at ) ;
beats . push ( {
actor ,
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 ) ) ,
} ) ;
}
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_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' ) ;
}
const waiting = [ . . . waitByTitle . entries ( ) ] . filter ( ( [ , s ] ) = > s === 'open' ) . map ( ( [ title ] ) = > ( { title } ) ) ;
// inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
const inboxNew = inboxEntries . reduce ( ( n , e ) = > {
const j = parseJsonContent ( e ) ;
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 ) ) ;
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 light : 'green' | 'yellow' | 'red' =
hasBlocked || ( inJudgeWindow && beatStale ) ? 'red' : hasLagMark ? 'yellow' : 'green' ;
const todayTasks = tasks . filter ( ( t ) = > t . scope === 'today' ) ;
return c . json ( {
light ,
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 ,
inbox_new : inboxNew ,
generated_at : new Date ( now ) . toISOString ( ) ,
} ) ;
} ) ;
function renderDashboardHtml ( ) : string {
return ` <!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>arcrun 駕駛艙</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "PingFang TC", "Noto Sans TC", sans-serif; background: #0f1115; color: #e6e6e6; }
main { padding: 14px; display: grid; gap: 14px; max-width: 560px; margin: 0 auto; }
.card { background: #161922; border: 1px solid #262a33; border-radius: 12px; padding: 16px; }
.light-row { display: flex; align-items: center; gap: 12px; }
.light-emoji { font-size: 44px; line-height: 1; }
.light-word { font-size: 20px; font-weight: 700; }
.beat { color: #9aa0aa; font-size: 13px; margin-top: 8px; }
.bar-wrap { margin-top: 12px; }
.bar-label { font-size: 12px; color: #9aa0aa; margin-bottom: 4px; display: flex; justify-content: space-between; }
.bar { height: 10px; background: #0f1115; border: 1px solid #262a33; border-radius: 999px; overflow: hidden; }
.bar > i { display: block; height: 100%; background: #3a5bfd; border-radius: 999px; transition: width .4s; }
.subhead { font-size: 12px; color: #9aa0aa; margin: 12px 0 4px; text-transform: uppercase; letter-spacing: .04em; }
.subhead:first-child { margin-top: 0; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
li { font-size: 14px; line-height: 1.5; }
.muted { color: #6b7280; font-size: 13px; }
.inbox { margin-top: 12px; font-size: 13px; color: #9aa0aa; }
.err { color: #f06565; font-size: 13px; }
.stamp { text-align: center; color: #4b5563; font-size: 11px; }
</style>
</head>
<body>
<main>
<div class="card" id="card-light">
<div class="light-row"><span class="light-emoji" id="light-emoji">⏳</span><span class="light-word" id="light-word">載入中</span></div>
<div class="beat" id="beat-line"></div>
<div class="bar-wrap">
<div class="bar-label"><span>今日完成度</span><span id="bar-num"></span></div>
<div class="bar"><i id="bar-fill" style="width:0%"></i></div>
</div>
</div>
<div class="card">
<div class="subhead">今日路線</div>
<ul id="today-list"><li class="muted">載入中...</li></ul>
<div class="subhead" id="week-head" style="display:none">本週</div>
<ul id="week-list"></ul>
<div class="subhead" style="margin-top:14px">等你的事</div>
<ul id="wait-list"></ul>
<div class="inbox" id="inbox-line"></div>
</div>
<div class="stamp" id="stamp"></div>
</main>
<script>
(function () {
const $ = (id) => document.getElementById(id);
const LIGHT = { green: ['🟢', '系統運轉中'], yellow: ['🟡', '落後趕工中'], red: ['🔴', '卡住'] };
const STATUS_EMOJI = { done: '✅', doing: '🔄', todo: '⬜', blocked: '⛔' };
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function taskLine(t) {
return '<li>' + (STATUS_EMOJI[t.status] || '⚠️') + ' ' + esc(t.title) + '</li>';
}
async function load() {
try {
const res = await fetch('/console/dashboard-data');
if (!res.ok) throw new Error('HTTP ' + res.status);
const d = await res.json();
const [emoji, word] = LIGHT[d.light] || ['⚪', d.light];
$ ('light-emoji').textContent = emoji;
$ ('light-word').textContent = word;
$ ('beat-line').textContent = d.last_beat
? '最後心跳 ' + d.last_beat.actor + ' ' + d.last_beat.ago_minutes + ' 分鐘前'
: '尚無心跳';
const done = d.today_done || 0, total = d.today_total || 0;
$ ('bar-num').textContent = done + ' / ' + total;
$ ('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
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="muted">今日無排定項目</li>';
$ ('week-head').style.display = week.length ? '' : 'none';
$ ('week-list').innerHTML = week.map(taskLine).join('');
$ ('wait-list').innerHTML = (d.waiting && d.waiting.length)
? d.waiting.map((w) => '<li>🙋 ' + esc(w.title) + '</li>').join('')
: '<li class="muted">無,你不用做任何事</li>';
$ ('inbox-line').textContent = '📥 收件匣未處理:' + (d.inbox_new || 0) + ' 件';
$ ('stamp').textContent = '更新於 ' + new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false });
} catch (e) {
$ ('light-emoji').textContent = '⚪';
$ ('light-word').textContent = '讀不到狀態';
$ ('beat-line').innerHTML = '<span class="err">' + esc(e.message) + '</span>';
}
}
load();
setInterval(load, 60000);
})();
</script>
</body>
</html>
` ;
}
// GET /console/dashboard — 駕駛艙頁(無需登入;純渲染 dashboard-data,無互動、無說明文字)
consoleDashboardRouter . get ( '/console/dashboard' , ( c ) = > c . html ( renderDashboardHtml ( ) ) ) ;