fix(console): 真實性稽核修正——時區統一台北、精耕層數字、今日完成接 sprint 任務板

稽核輪(fix/console-truth-audit,總管派工+leo 三裁決):

B1 時區:console 全站日期/時間顯示統一 Asia/Taipei(新 lib/taipei-time.ts,
   server 判定與頁面 client JS 同一套、等價性單測互鎖)。原本用瀏覽器本地
   getter——換裝置就換日期;憑證頁改顯「日期+時分(台北)」:raw 1783349834
   =台北 2026-07-06 22:57,7/6 顯示是真的,缺的是時分與固定時區。

B2 總庫數字(leo 裁:14-E 遺產 deprecated 不再顯示):總庫搜尋/設定頁頭部
   統計改讀新 GET /console/kb-scale-data(精耕層 wiki 卡/三元組/已嵌入+最近
   寫入,全 live、limit=1/stats 聚合不掃庫);搜尋仍搜全庫、結果 wiki_card
   加「精耕」綠標;dashboard-data 的 entries 計數補 owner_id(原混租戶:
   459,137 vs leo 458,732)。

B3 設定頁:vectorize 文案改具體事實(config kbdb_embed+acr update,#32
   教訓)+live 狀態;新增 MCP token TTL 誠實佔位(GET /console/settings-data,
   可調功能=Arcrun#19,不假裝能遠端改)。

C 今日完成/今日路線接 sprint 任務板(leo 拍板):比照 #36 等leo清單模式,
   同一輪 Gitea fetch(90s CF cache 共用)解析「## 任務板」checkbox;「今日
   完成」只認「完成(今天台北日)」標記,[x] 無日期不冒領;快取存原始
   completed_days,跨台北午夜不誤算;板檔今天沒 commit → 頁面誠實標「今日
   任務板未更新」;dash_task 降 fallback、燈號判定不變。parser 以 live
   07a/07b 檔實測(標題抽取棄「首個粗體」——實讀發現會抽到完成註記)。

驗證:tsc exit 0;vitest 82/83(1 失敗=pre-existing executor「不存在的
零件」,stash 於 main 複驗同樣紅);新增 taipei-time 10 案例(含跨日/跨年/
client-server 等價/leo 實測 epoch)+任務板 parser 7 案例;兩頁 inline JS
渲染後抽 <script> node --check 全過(mistakes #24 手法)。
This commit is contained in:
cloud-worker
2026-07-07 12:25:31 +00:00
parent ef779e3ef6
commit 5690d61438
9 changed files with 660 additions and 138 deletions
@@ -16,6 +16,12 @@
* → 燈號只吃「今日寫入」的任務 + 心跳(心跳 dash_beat 是唯一有活管線的 dash_* 資料)。
*/
import { taipeiDayKey } from './taipei-time';
// 台北日曆日 key 的唯一實作搬到 lib/taipei-time.tsfix/console-truth-auditconsole 全站
// 日期顯示統一台北,server/client 共用同一套)。這裡 re-export 維持既有 import 路徑不破。
export { taipeiDayKey };
export interface KbdbEntry {
id: string;
content: string | null;
@@ -47,11 +53,6 @@ export function parseJsonContent(e: KbdbEntry): Record<string, unknown> | 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));
}
@@ -194,19 +195,8 @@ export function cleanWaitingTitle(raw: string): string {
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;
// 截斷可能留下未閉合的全形括號 → 共用 trimUnbalancedParen(板標題同款處理)
return trimUnbalancedParen(s);
}
/** sprint 檔命名慣例 sprint-YYYY-MM<letter>.md,字典序即時間序。
@@ -250,6 +240,113 @@ export function buildWaitingFallback(entries: KbdbEntry[], nowMs: number): Waiti
};
}
// ── sprint 任務板(「今日完成」活資料源,leo 2026-07-07 拍板)──────────────────────
//
// 動機(leo 原話):「今天做了這麼多事……其實就是我們到底完成了多少事」。dash_task 沒有
// 活管線(同 dash_wait 病),真相源=InkStoneCo sprint 檔「## 任務板」(progress-guard /
// cloud-worker 每日維護勾選)。比照「等你的事」#36 模式:同一輪 Gitea fetch 撈的檔案
// 順手解析任務板,dash_task 降為 fallback。
export interface SprintBoardTask {
title: string;
/** [x]=done、[🔄]=doing、[ ]=todo、[!]=blocked(板上維護慣例,對照 sprint-2026-07a.md */
status: 'done' | 'doing' | 'todo' | 'blocked';
/** 行內所有「完成(YYYY-MM-DD…)」標記的日期(一行可有多次完成,如 T6-T9 分兩天)。
* [x] 但沒有帶日期的完成標記 → 空陣列=無法歸日,任何一天都不冒領(寧可略過不誤判)。 */
completed_days: string[];
/** 合併多個 sprint 檔時標出處(如 "sprint-2026-07b.md" */
sprint?: string;
}
const BOARD_LINE = /^- \[( |x|X|🔄|!)\]\s*(.*)$/u;
const COMPLETED_DAY = /(\d{4}-\d{2}-\d{2})/gu;
/** 截斷/切冒號後可能留下未閉合的全形括號 → 從最後一個未配對「(」剪掉。
* 整串都在括號裡(如「(T10 廢 ENCRYPTION_KEY:…」註記行)會剪成空字串——
* caller 視空標題為「不是任務」跳過,恰好把括號註記行濾掉。 */
function trimUnbalancedParen(s: string): string {
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;
}
/** 板上事項標題=「第一個全形冒號前」的裸文字(板的維護慣例:`**任務代號**:長敘述…`)。
* 不能用「第一個粗體」——實讀 07a 發現長敘述裡的 `**完成(2026-07-02…)**` 註記常比
* 任務名先出現粗體,會抽成「完成(…)」假標題。冒號太前(<4 字,如「附帶:」)不切。 */
export function cleanBoardTitle(raw: string): string {
let s = raw.replace(/\*\*/g, '').replace(/~~/g, '').replace(/`/g, '').trim();
const colon = s.indexOf('');
if (colon >= 4) s = s.slice(0, colon);
if (s.length > 80) s = `${s.slice(0, 79)}`;
return trimUnbalancedParen(s.trim());
}
/**
* 從 sprint md 撈「## 任務板」段(到下一個 `## ` 為止,`###` 子段照收),解析頂層
* checkbox 事項。只認**行首**的 `- [x]` 型式——縮排子彈(「 - 做法:」)與無 checkbox
* 的候補區純敘述行都不是任務,natural 濾掉。找不到任務板段落 → null(caller 誠實
* fallback dash_task,不硬湊)。解析不了的行一律略過,不誤判成完成(寬鬆容錯原則)。
*/
export function parseSprintTaskBoard(md: string, sprintFile?: string): SprintBoardTask[] | null {
const secIdx = md.search(/^##\s*/mu);
if (secIdx < 0) return null;
const section = md.slice(secIdx);
const lines = section.split('\n');
const tasks: SprintBoardTask[] = [];
for (const line of lines.slice(1)) {
if (/^##\s/.test(line) && !/^###/.test(line)) break; // 下一個 H2 段落,任務板結束
const m = line.match(BOARD_LINE);
if (!m) continue;
const mark = m[1];
const body = m[2];
const title = cleanBoardTitle(body);
if (!title) continue;
const completed: string[] = [];
for (const cm of body.matchAll(COMPLETED_DAY)) completed.push(cm[1]);
tasks.push({
title,
status: mark === 'x' || mark === 'X' ? 'done' : mark === '🔄' ? 'doing' : mark === '!' ? 'blocked' : 'todo',
completed_days: completed,
sprint: sprintFile,
});
}
return tasks.length ? tasks : null;
}
export interface SprintRouteModel {
/** 今日路線清單:今天完成的(done)+ 板上仍開著的(doing/todo/blocked),板序即優先序 */
tasks: { title: string; status: 'done' | 'doing' | 'todo' | 'blocked'; sprint?: string }[];
today_done: number;
/** 今日完成 + 仍開著的板上事項(今天的可做面)——不是「今天排定量」,板不按日排定 */
today_total: number;
done_today_titles: string[];
}
/**
* 任務板 →「今日完成」模型:done 只數「完成標記日期=今天(台北)」的項;歷史完成項
* (前幾天勾的)不進今日清單也不進計數——不冒領昨天的功。開著的項照板序全列(今日路線)。
*/
export function buildSprintRouteModel(tasks: SprintBoardTask[], nowMs: number): SprintRouteModel {
const todayKey = taipeiDayKey(nowMs);
const doneToday = tasks.filter((t) => t.status === 'done' && t.completed_days.includes(todayKey));
const open = tasks.filter((t) => t.status !== 'done');
return {
tasks: [...doneToday, ...open].map((t) => ({ title: t.title, status: t.status, sprint: t.sprint })),
today_done: doneToday.length,
today_total: doneToday.length + open.length,
done_today_titles: doneToday.map((t) => t.title),
};
}
/** 人話 age:分鐘 → 「3 分鐘前 / 5 小時前 / 3 天前」。-1 → 「時間不明」。 */
export function humanAge(minutes: number): string {
if (minutes < 0) return '時間不明';
@@ -265,9 +362,18 @@ export function humanAge(minutes: number): string {
* 等leo清單由 progress-guard 每日維護,90 秒新鮮度綽綽有餘。 */
export const GITEA_WAITING_CACHE_TTL_SECONDS = 90;
/** Cache API 裡存的封套:模型 + fetch 當下的牆鐘(回放時補算 age 用)。 */
/** 同一輪 Gitea fetch 的兩個產物:等你的事(等leo清單)+ 今日完成/路線(任務板)。
* 板任務存「原始 completed_days」而非「今天完成幾件」——今天是誰由請求當下判定
* buildSprintRouteModel),快取跨台北午夜也不會把昨天的完成假裝成今天的。 */
export interface SprintSnapshot {
waiting: WaitingModel;
/** null = 檔案讀到了但沒有可解析的任務板(caller fallback dash_task */
board: SprintBoardTask[] | null;
}
/** Cache API 裡存的封套:快照 + fetch 當下的牆鐘(回放時補算 age 用)。 */
export interface CachedWaitingEnvelope {
model: WaitingModel;
snapshot: SprintSnapshot;
fetched_at_ms: number;
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Asia/TaipeiUTC+8,無日光節約)時間顯示 helperfix/console-truth-audit2026-07-07
*
* 為什麼存在:console 各頁原本用瀏覽器本地時區(`Date#getFullYear/getMonth/...`)渲染
* created_at / last_used_at / 刷新時間戳——顯示結果隨看的裝置漂移(換個時區的裝置就換一個
* 日期),且與駕駛艙 #36 的「台北日曆日」判定(`taipeiDayKey`)不是同一套。本檔把
* 「epoch ms → 台北牆鐘」收斂成唯一實作:
* - server 側(TS):dashboard-data 的台北日判定 import 這裡。
* - client 側(頁面 inline JS):console.ts / console-dashboard.ts 的 HTML 內嵌
* `TAIPEI_CLIENT_JS`(同邏輯的 ES5 版)。等價性由 tests/taipei-time.test.ts 用
* `new Function` 求值 client 版與 server 版逐案對照,防兩份漂移。
*
* 台北無 DST、固定 +8——用「UTC getter +固定位移」即可,不需要 Intl 時區資料庫。
*/
export const TAIPEI_OFFSET_MS = 8 * 3600 * 1000;
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
/** 台北日曆日 key'YYYY-MM-DD')。#36 駕駛艙「這筆是不是今天寫的」判定用。 */
export function taipeiDayKey(ms: number): string {
return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10);
}
/** 台北日期字串 'YYYY-MM-DD'(顯示用,同 taipeiDayKey,語意別名)。 */
export function taipeiDateStr(ms: number): string {
return taipeiDayKey(ms);
}
/** 台北日期+時分 'YYYY-MM-DD HH:MM'。 */
export function taipeiDateTimeStr(ms: number): string {
const d = new Date(ms + TAIPEI_OFFSET_MS);
return `${taipeiDayKey(ms)} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
}
/** 台北時分秒 'HH:MM:SS'(駕駛艙「上次刷新」戳記用)。 */
export function taipeiTimeStr(ms: number): string {
const d = new Date(ms + TAIPEI_OFFSET_MS);
return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`;
}
/**
* client 版(ES5,內嵌進 console 頁 inline <script>)。與上面 server 版同邏輯——
* 測試以 `new Function(TAIPEI_CLIENT_JS + 'return {...}')()` 求值後逐案對照 server 版,
* 任一份改了另一份沒跟上會直接紅。頁面內以 `${TAIPEI_CLIENT_JS}` 插入,故這裡不用
* template literal 語法(頁面本身是 template string,反引號會提前終結)。
*/
export const TAIPEI_CLIENT_JS = [
"var TAIPEI_OFFSET_MS = 28800000; // UTC+8,台北無 DST",
"function tpePad(n) { n = String(n); return n.length < 2 ? '0' + n : n; }",
"function taipeiDayKey(ms) { return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10); }",
"function taipeiDateStr(ms) { return taipeiDayKey(ms); }",
"function taipeiDateTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return taipeiDayKey(ms) + ' ' + tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()); }",
"function taipeiTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()) + ':' + tpePad(d.getUTCSeconds()); }",
"function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { month: d.getUTCMonth() + 1, day: d.getUTCDate() }; }",
].join('\n');
+172 -58
View File
@@ -2,9 +2,18 @@
* arcrun console 駕駛艙 dashboardT-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工;
* 2026-07-07 fix/console-dashboard-live-datastale 資料整修,總管交辦)
*
* 兩個端點皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
* 端點皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
* - GET /console/dashboard-data:聚合 JSON。
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),每 60 秒自動刷新。
* - GET /console/kb-scale-data:精耕層規模(wiki 卡/三元組/已嵌入;2026-07-07 leo 裁
* 「遺產庫不用顯示」後 console 頭部統計改讀這裡)。
* - GET /console/settings-data:設定頁誠實系統值(MCP token TTL 佔位)。
*
* ── 2026-07-07 二修(fix/console-truth-audit):「今日完成/今日路線」接 sprint 任務板 ──
* leo 拍板(「今天做了這麼多事……其實就是我們到底完成了多少事」):dash_task 同 dash_wait
* 病(沒活管線),真相源=sprint 檔「## 任務板」勾選。比照「等你的事」#36 模式:同一輪
* Gitea fetch(90s 快取共用)解析任務板,「今日完成」只認「完成(今天台北日)」標記,
* dash_task 降 fallback;板檔今天沒 commit → 頁面誠實標「今日任務板未更新(最後 N 小時前)」。
*
* ── 2026-07-07 整修:每個區塊都讀「live 一手資料」,讀不到就誠實標示,不擺 stale 殘骸 ──
*
@@ -43,17 +52,23 @@ import {
type WaitingItem,
type WaitingModel,
type CachedWaitingEnvelope,
type SprintBoardTask,
type SprintSnapshot,
GITEA_WAITING_CACHE_TTL_SECONDS,
parseCreatedAtMs,
parseJsonContent,
agoMinutes,
buildRouteModel,
buildSprintRouteModel,
buildWaitingFallback,
parseSprintTaskBoard,
parseSprintWaitingTable,
pickLatestSprintFiles,
reviveWaitingAges,
sortWaitingItems,
taipeiDayKey,
} from '../lib/console-dashboard-model';
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
@@ -95,13 +110,16 @@ async function fetchEntryTotal(env: Bindings, filters: Record<string, string>):
}
/**
* 「等你的事」活資料源:InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護)。
* sprint 檔活資料源(同一輪 fetch 兩個產物,leo 2026-07-07 拍板加「今日完成」):
* - 「等你的事」=「## 等 leo 清單」表格(progress-guard 每日維護)。
* - 「今日完成/今日路線」=「## 任務板」checkbox(今天勾的才算今日完成)。
* 需 GITEA_BASE_URLvar+ GITEA_TOKENsecret,建議唯讀 scope)。請求序:
* 列目錄挑最新兩個 sprint-*.md(換 sprint 後前一檔常還有未銷案項,例:07b 開了、
* 🔴 mira 憑證外洩仍掛 07a)→ 各抓 raw 解析合併 → 最新檔的最後 commit 時間當「清單維護於」。
* 任一步失敗回 null → caller fallback dash_wait(標 age),不硬湊。
* 列目錄挑最新兩個 sprint-*.md(換 sprint 後前一檔常還有未銷案項/未收項,例:07b 開了、
* 🔴 mira 憑證外洩與 [🔄] T-cockpit 仍掛 07a)→ 各抓 raw、兩個 parser 吃同一份文字 →
* 最新檔的最後 commit 時間當「維護於」。等leo清單全解析失敗回 null → caller fallback
* dash_wait / dash_task(標 age),不硬湊。
*/
async function fetchGiteaWaiting(env: Bindings, nowMs: number): Promise<WaitingModel | null> {
async function fetchGiteaSprint(env: Bindings, nowMs: number): Promise<SprintSnapshot | null> {
const base = (env.GITEA_BASE_URL ?? '').replace(/\/$/, '');
const token = env.GITEA_TOKEN;
if (!base || !token) return null;
@@ -117,12 +135,15 @@ async function fetchGiteaWaiting(env: Bindings, nowMs: number): Promise<WaitingM
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 text = await rawRes.text();
return { waiting: parseSprintWaitingTable(text, name), board: parseSprintTaskBoard(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
const readFiles = sprints.filter((_, i) => parsed[i]?.waiting != null);
const merged = parsed.map((p) => p?.waiting).filter((p): p is WaitingItem[] => p != null).flat();
if (!readFiles.length) return null; // 等leo清單全部解析失敗=誠實 fallback
// 任務板:新→舊合併(現役 sprint 的板先列);兩檔都沒有可解析的板 → nullfallback dash_task
const boardMerged = parsed.map((p) => p?.board).filter((b): b is SprintBoardTask[] => b != null).flat();
// 清單上次維護時間 = 現役 sprint 檔最後 commitprogress-guard 每日 commit>48h 沒動才算 stale
let ago = -1;
const commits = await fetchJson<{ commit?: { committer?: { date?: string } } }[]>(
@@ -135,40 +156,44 @@ async function fetchGiteaWaiting(env: Bindings, nowMs: number): Promise<WaitingM
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,
waiting: {
items: sortWaitingItems(merged),
source: 'gitea_sprint',
updated_ago_minutes: ago,
stale: ago >= 0 && ago > 48 * 60,
sprint_files: readFiles,
},
board: boardMerged.length ? boardMerged : null,
};
} catch {
return null;
}
}
export type GiteaWaitingFetcher = (env: Bindings, nowMs: number) => Promise<WaitingModel | null>;
export type GiteaSprintFetcher = (env: Bindings, nowMs: number) => Promise<SprintSnapshot | null>;
/**
* fetchGiteaWaiting 的快取層(總管 #36 審查要求):CF Cache APIcaches.default)、
* fetchGiteaSprint 的快取層(總管 #36 審查要求):CF Cache APIcaches.default)、
* TTL 90sGITEA_WAITING_CACHE_TTL_SECONDS)。前端 60 秒刷新下,Gitea 從
* 「每分鐘 3-4 個 API call」降到「≤1 輪/90s」;快取是查詢面的讀優化,不是輪詢。
*
* - key:合成 URLCache API 要求合法 URL;host 用不會真的被打的保留名),帶
* base/repo/dir 參數——設定變了自然 miss,不會吐到別的 Gitea 的殘資料。
* - hit 回放時用 reviveWaitingAges 把「維護於 N 分鐘前」隨牆鐘補算(存的是 fetch
* 當下的 ago,直接回放會讓時間停走)。
* 當下的 ago,直接回放會讓時間停走)。任務板存原始 completed_days,「今天完成幾件」
* 由請求當下算——跨台北午夜的快取不會把昨天的完成冒領成今天。
* - **失敗不快取**negative cache 會把一時網路抖動放大成 90 秒盲區,caller 該
* 當場 fallback dash_wait。
* 當場 fallback dash_wait / dash_task
* - cache.put 走 waitUntil(不阻塞回應);fetcher 參數可注入=單測不用真打網路。
* - 回傳多帶 cache:'hit'|'miss',吐進 waiting_meta 當快取生效的客觀證據(curl 兩次
* 第二次該是 hit)。
*/
export async function cachedGiteaWaiting(
export async function cachedGiteaSprint(
env: Bindings,
nowMs: number,
waitUntil: (p: Promise<unknown>) => void,
fetcher: GiteaWaitingFetcher = fetchGiteaWaiting,
): Promise<(WaitingModel & { cache: 'hit' | 'miss' }) | null> {
fetcher: GiteaSprintFetcher = fetchGiteaSprint,
): Promise<(SprintSnapshot & { cache: 'hit' | 'miss' }) | null> {
if (!env.GITEA_BASE_URL || !env.GITEA_TOKEN) return null;
const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
@@ -180,14 +205,18 @@ export async function cachedGiteaWaiting(
const hit = await cache.match(cacheKey);
if (hit) {
const envelope = (await hit.json()) as CachedWaitingEnvelope;
return { ...reviveWaitingAges(envelope.model, envelope.fetched_at_ms, nowMs), cache: 'hit' };
return {
waiting: reviveWaitingAges(envelope.snapshot.waiting, envelope.fetched_at_ms, nowMs),
board: envelope.snapshot.board,
cache: 'hit',
};
}
} catch {
/* cache 故障不致命,走 miss 路徑 */
}
const fresh = await fetcher(env, nowMs);
if (!fresh) return null; // 失敗不快取,caller 誠實 fallback
const envelope: CachedWaitingEnvelope = { model: fresh, fetched_at_ms: nowMs };
const envelope: CachedWaitingEnvelope = { snapshot: fresh, fetched_at_ms: nowMs };
try {
waitUntil(
cache.put(
@@ -218,7 +247,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
taskEntries,
waitEntries,
inboxEntries,
giteaWaiting,
giteaSprint,
kbdbHealth,
embedStatus,
graphStats,
@@ -230,12 +259,13 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
fetchEntries(c.env, tenant, 'dash_task', 200),
fetchEntries(c.env, tenant, 'dash_wait', 100),
fetchEntries(c.env, tenant, 'inbox', 200),
cachedGiteaWaiting(c.env, now, (p) => c.executionCtx.waitUntil(p)),
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
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' }),
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732
fetchEntryTotal(c.env, { owner_id: tenant }),
fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
fetchEntryTotal(c.env, { entry_type: 'workflow', owner_id: tenant }),
]);
@@ -258,16 +288,12 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
}
const lastBeat = beats.filter((b) => b.ago_minutes >= 0).sort((a, b) => a.ago_minutes - b.ago_minutes)[0] ?? null;
// 今日路線:dash_task + 台北日曆日判定(純函式,見 lib/console-dashboard-model.ts
const route = buildRouteModel(taskEntries, now);
// 等你的事:Gitea sprint 等leo清單優先(走 90s 快取);讀不到 fallback dash_wait(帶 age + stale
let waiting: WaitingModel;
let waitingCache: 'hit' | 'miss' | null = null;
if (giteaWaiting) {
const { cache: cacheState, ...model } = giteaWaiting;
waiting = model;
waitingCache = cacheState;
if (giteaSprint) {
waiting = giteaSprint.waiting;
waitingCache = giteaSprint.cache;
} else {
waiting = buildWaitingFallback(waitEntries, now);
if (waiting.source === 'kbdb_dash_wait' && !(c.env.GITEA_BASE_URL && c.env.GITEA_TOKEN)) {
@@ -277,6 +303,14 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
}
}
// 今日完成/今日路線:sprint 任務板優先(leo 2026-07-07 拍板——「到底完成了多少事」的
// 真相源=progress-guard/cloud-worker 每日勾選的板,dash_task 沒活管線降 fallback)。
// 板的「今日完成」只認「完成(今天台北日)」標記;板檔今天沒 commit 過 → 誠實標示。
const sprintRoute = giteaSprint?.board ? buildSprintRouteModel(giteaSprint.board, now) : null;
const route = buildRouteModel(taskEntries, now); // fallback 燈號仍吃 dash_task 今日寫入
const boardAgo = giteaSprint ? giteaSprint.waiting.updated_ago_minutes : -1;
const boardUpdatedToday = boardAgo >= 0 && taipeiDayKey(now - boardAgo * 60000) === taipeiDayKey(now);
// inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
const inboxNew = inboxEntries.reduce((n, e) => {
const j = parseJsonContent(e);
@@ -308,22 +342,45 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
light_reason: lightReason,
last_beat: lastBeat ? { actor: lastBeat.actor, ago_minutes: lastBeat.ago_minutes, event: lastBeat.event, note: lastBeat.note } : null,
beats,
// 路線: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,
// 路線:sprint 任務板優先(tasks 欄位形狀與 dash_task 版相容——title/status/scope);
// 板上開著的項 is_today_write=false(燈號沿 #36 原則只吃 dash_task 今日寫入+心跳+KBDB
// 板上掛了幾天的 [!] 不會天天亮紅燈——那是「等裁決」不是「今天卡住」)
tasks: sprintRoute
? sprintRoute.tasks.map((t, i) => ({
title: t.title,
status: t.status,
order: i,
scope: 'today' as const,
age_minutes: boardAgo,
is_today_write: t.status === 'done', // done 項必然是「今天完成」的(模型已濾)
sprint: t.sprint ?? null,
}))
: 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,
sprint: null,
})),
route_meta: sprintRoute
? {
source: 'gitea_sprint_board',
// is_today=板檔今天(台北)有 commit 過;false → 頁面誠實標「今日任務板未更新」
is_today: boardUpdatedToday,
updated_ago_minutes: boardAgo,
sprint_files: waiting.sprint_files ?? null,
}
: {
source: 'kbdb_dash_task',
is_today: route.is_today,
updated_ago_minutes: route.updated_ago_minutes,
sprint_files: null,
},
today_done: sprintRoute ? sprintRoute.today_done : route.today_done,
today_total: sprintRoute ? sprintRoute.today_total : route.today_total,
done_today_titles: sprintRoute ? sprintRoute.done_today_titles : null,
waiting: waiting.items,
waiting_meta: {
source: waiting.source,
@@ -352,6 +409,51 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
});
});
// GET /console/kb-scale-data — 總庫「精耕層」規模(leo 2026-07-07 裁:45.8 萬 14-E 搬遷
// blocks 已 deprecated 之後要刪,頭部統計**不再拿遺產數字撐場面**,只顯示真的新的)。
// 免登入(純聚合計數、無內容原文,同 dashboard-data 標準)。3 個 subrequest,全是
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const { base, headers } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
const now = Date.now();
const [wikiCards, graphStats, embedStatus] = await Promise.all([
// limit=1 順手拿最新一筆 created_atlist 為 created_at DESC)=「最近寫入時間」
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
headers,
),
fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`),
fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
]);
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
// 讀不到的欄位誠實回 null(頁面顯示「讀不到」),不編數字
return c.json({
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null,
embedded: embedStatus?.embedded ?? null,
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
generated_at: new Date(now).toISOString(),
});
});
// GET /console/settings-data — 設定頁的誠實系統值(目前只有 MCP token TTL 佔位區塊用)。
// TTL 真相住在 mcp worker 部署端 env `MCP_TOKEN_TTL`mcp/src/types.ts,預設 259200030 天);
// cypher 讀的是自己這份同名 var(deploy 時兩處要一致,#32 形態 config 同步教訓)——
// source 欄位如實標 env/default,頁面不假裝這是能遠端改的設定。
consoleDashboardRouter.get('/console/settings-data', (c) => {
const raw = c.env.MCP_TOKEN_TTL;
const parsed = raw ? parseInt(raw, 10) : NaN;
const fromEnv = Number.isFinite(parsed) && parsed > 0;
return c.json({
mcp_token_ttl_seconds: fromEnv ? parsed : 2592000,
mcp_token_ttl_source: fromEnv ? 'env' : 'default',
});
});
// GET /console/inbox-data — 收件匣清單(Mira Console 頁 7)。**需 console session**Bearer):
// dashboard-data 只吐計數可免登入;這裡吐訊息原文(leo 丟給 Telegram bot 的指令內容)屬機敏,鎖登入。
// 資料源同 dashboard-data 的 inbox entriesentry_type=inboxcontent JSON {"text","status","result"?})。
@@ -517,6 +619,8 @@ function renderDashboardHtml(): string {
</main>
<script>
(function () {
// 台北時間 helperlib/taipei-time.ts 注入,與 server 判定同一套——顯示不隨看的裝置時區漂移)
${TAIPEI_CLIENT_JS}
const $ = (id) => document.getElementById(id);
const LIGHT = {
green: { ch: '安', title: '系統運轉中', grad: 'radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)', anim: 'breatheGreen' },
@@ -540,8 +644,9 @@ function renderDashboardHtml(): string {
}
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();
$('date-str').textContent = CNUM[now.getMonth() + 1] + '月' + cnDay(now.getDate()) + '日';
// 頁首日期=台北日(原本用瀏覽器本地時區,換裝置會漂)
const nowTpe = taipeiMonthDay(Date.now());
$('date-str').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
// 深/淺切換(與 /console 共用 arcrun_console_theme;預設淺色)
function syncThemeBtn() { $('theme-btn').textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☾'; }
$('theme-btn').addEventListener('click', () => {
@@ -601,11 +706,19 @@ function renderDashboardHtml(): string {
metaTxt = '<span class="warn">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
}
wmeta.innerHTML = metaTxt;
// ── 今日路線:非今日寫入=誠實降級「最後路線(N 天前)」,不假裝是今天的 ──
// ── 今日路線:sprint 任務板優先(來源攤開講);dash_task fallback 沿舊誠實降級 ──
const rm = d.route_meta || {};
const today = (d.tasks || []).filter((t) => t.scope === 'today');
const week = (d.tasks || []).filter((t) => t.scope === 'week');
if (rm.is_today) {
if (rm.source === 'gitea_sprint_board') {
$('route-m').textContent = '來源 sprint 任務板・更新於 ' + humanAge(rm.updated_ago_minutes);
$('today-list').className = 'route';
const staleHead = rm.is_today ? '' :
'<li class="todo"><span class="ic">○</span><span class="muted">⚠ 今日任務板未更新(最後 ' + humanAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';
$('today-list').innerHTML = staleHead + (today.length
? today.map(taskLine).join('')
: '<li class="todo"><span class="ic">○</span><span class="muted">任務板上沒有可解析的事項</span></li>');
} else 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>';
@@ -637,10 +750,11 @@ function renderDashboardHtml(): string {
? 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' : ''));
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
// 三元組/已嵌入 已各有一列)
rows.push(sysRow('精耕層 wiki 卡', kb.wiki_card_total == null ? '讀不到' : kb.wiki_card_total + ' 張', kb.wiki_card_total == null ? 'off' : ''));
$('sys-list').innerHTML = rows.join('');
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false })) + '<br>此頁不含機敏內容,免登入';
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(taipeiTimeStr(Date.parse(d.generated_at))) + '(台北)<br>此頁不含機敏內容,免登入';
} catch (e) {
$('orb-char').textContent = '';
$('orb-title').textContent = '讀不到狀態';
+85 -37
View File
@@ -15,7 +15,8 @@
* - 駕駛艙:GET /console/dashboard-data(免登入聚合端點,console-dashboard.ts
* - 總庫搜尋:GET /kbdb/searchmode=semantic 未開 Vectorize 會誠實降級 + capability_hint
* chips 用 entry_type——KBDB 真能篩的欄位,不編造「電腦筆記/手機筆記」等不存在的分類);
* 規模感 total 來自 GET /kbdb/entries?limit=1 的誠實 total 欄位
* 頭部統計來自 GET /console/kb-scale-data(精耕層 wiki 卡/三元組/已嵌入——leo 2026-07-07
* 裁:45.8 萬 14-E 搬遷遺產 deprecated 不再當招牌數字;搜尋本身仍搜全庫)
* - 卡片詳頁:GET /kbdb/entries/:id;關聯視圖走 GET /kbdb/graph/neighbors/:name
* cypher 代轉 kbdb-graph-pluginkbdb-proxy.ts;查無 triplet → 「尚無關聯資料」)
* - 工作流:GET /webhooks/namedlist+ POST /webhooks/named/:name/trigger(手動觸發)
@@ -24,7 +25,16 @@
* + PUT /credentials/:name(整筆替換);刪除後端未接 D1(T9 範圍)→ 標「即將開通」
* - 收件匣:GET /console/inbox-data(需 session——訊息原文屬機敏,計數才免登入)
* - 設定:vectorize 狀態探測(讀 /kbdb/search 回應的 mode/capability_hint,無新端點;
* 開關本體需部署端啟用 kbdb_embed → 標「即將開通」)+ /console/setup/reset 換帳密 + 系統資訊
* 開關本體需部署端 config kbdb_embed + acr update → 誠實文案講清楚、不假裝能遠端開)
* + MCP token TTL 誠實佔位(/console/settings-data;可調功能=Arcrun#19
* + /console/setup/reset 換帳密 + 系統資訊(精耕層規模)
*
* 2026-07-07 fix/console-truth-audit(總管稽核派工 + leo 裁決):
* 1. 全站日期/時間顯示統一 Asia/Taipeilib/taipei-time.ts,與駕駛艙 #36 台北日判定同套;
* 原本用瀏覽器本地 getter,換裝置就換日期;憑證頁加顯時分——7/6 22:57 建立不再被誤讀)。
* 2. 總庫搜尋/設定頁頭部統計改精耕層(458,732 是真數字——cypher 與 base 一手同值實證——
* 但 leo 裁 14-E 遺產 deprecated 不再顯示)。
* 3. 駕駛艙「今日完成/今日路線」接 sprint 任務板真資料(dashboard-data 端,同 #36 模式)。
*
* 認證模型沿 v1console-auth.ts):單一管理員 email+passwordsession token 在 localStorage
* 實際打 /kbdb/* 用後端回的固定租戶字串(CONSOLE_TENANT)。駕駛艙免登入版在 /console/dashboard。
@@ -42,6 +52,7 @@
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
export const consoleRouter = new Hono<{ Bindings: Bindings }>();
@@ -331,7 +342,7 @@ function renderConsoleHtml(registryBase: string): string {
</div>
<div>
<div style="display:flex;justify-content:space-between;align-items:baseline;margin:24px 0 10px">
<span class="subhead">今日路線</span><span style="font-size:13px;color:rgba(var(--ink-rgb),.4)">狀態即時同步</span>
<span class="subhead">今日路線</span><span id="ck-route-src" style="font-size:13px;color:rgba(var(--ink-rgb),.4)">狀態即時同步</span>
</div>
<ul class="route" id="ck-today"><li class="todo"><span class="ic">○</span><span class="muted">載入中…</span></li></ul>
<div class="subhead" id="ck-weekhead" style="display:none;margin:18px 0 10px">本週</div>
@@ -431,10 +442,17 @@ function renderConsoleHtml(registryBase: string): string {
<div style="font-size:17px;font-weight:600">語意搜尋(vectorize</div>
<div id="st-vec" style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">狀態偵測中…</div>
</div>
<div class="switch" id="st-vec-switch" title="開關即將開通"><i></i></div>
<div class="switch" id="st-vec-switch" title="開關不能遠端改,只如實顯示狀態"><i></i></div>
</div>
<div style="margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
誠實提示:這裡的開關<b style="color:var(--amber)">即將開通</b>——啟用/停用需在部署端設定(acr config 開 kbdb_embed 後重新部署),Console 目前只如實顯示狀態,不假裝能遠端開啟。
誠實提示:開關真相=部署端 <code style="font-size:12.5px">~/.arcrun/config.yaml</code> 的 <code style="font-size:12.5px">kbdb_embed: true</code> <code style="font-size:12.5px">acr update</code> 重部署(#32 教訓:wrangler 直推改的形態 config 要同步)。Console 只如實顯示狀態,不假裝能遠端開啟。目前狀態:<b style="color:var(--amber)" id="st-vec-state">偵測中…</b>
</div>
</div>
<div class="panel">
<div style="font-size:17px;font-weight:600">MCP token 有效期(TTL</div>
<div id="st-mcp-ttl" style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">讀取中…</div>
<div style="margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
誠實佔位:此頁<b style="color:var(--amber)">還不能改</b>這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env <code style="font-size:12.5px">MCP_TOKEN_TTL</code>mcp worker)設定。
</div>
</div>
<div class="panel">
@@ -480,6 +498,9 @@ function renderConsoleHtml(registryBase: string): string {
<script>
(function () {
'use strict';
// 台北時間 helperlib/taipei-time.ts 注入,與駕駛艙 #36 台北日判定同一套)——
// console 所有日期/時間顯示固定 Asia/Taipei,不隨看的裝置時區漂移
${TAIPEI_CLIENT_JS}
var REGISTRY_BASE = ${JSON.stringify(registryBase)};
var $ = function (id) { return document.getElementById(id); };
@@ -487,7 +508,6 @@ function renderConsoleHtml(registryBase: string): string {
var S = {
token: localStorage.getItem('arcrun_console_session') || '',
tenant: '',
total: null, // KBDB 總筆數(誠實 total
chip: '', // entry_type filter'' = 全部)
semantic: false,
cardId: '',
@@ -545,19 +565,16 @@ function renderConsoleHtml(registryBase: string): string {
var ms = Date.parse(s);
return isNaN(ms) ? null : ms;
}
// 顯示一律台北時間(bug 修正:原本用瀏覽器本地 getFullYear/getHours——顯示結果
// 隨看的裝置時區漂移,且與後端「台北日」判定不同套;憑證頁 7/6 22:57(台北)建立的
// credential 在非 +8 裝置上會顯示成另一天)
function fmtDate(v) {
var ms = parseAtMs(v);
if (ms == null) return '';
var d = new Date(ms);
var p = function (n) { return String(n).padStart(2, '0'); };
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
return ms == null ? '' : taipeiDateStr(ms);
}
function fmtDateTime(v) {
var ms = parseAtMs(v);
if (ms == null) return '';
var d = new Date(ms);
var p = function (n) { return String(n).padStart(2, '0'); };
return fmtDate(v) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
return ms == null ? '' : taipeiDateTimeStr(ms);
}
// ── 極簡 Markdown 渲染(先全 escape 再上格式,防 XSS)──
@@ -707,8 +724,8 @@ function renderConsoleHtml(registryBase: string): string {
return Math.round(m / 1440) + ' 天前';
}
function loadCockpit() {
var now = new Date();
$('ck-date').textContent = CNUM[now.getMonth() + 1] + '月' + cnDay(now.getDate()) + '日';
var nowTpe = taipeiMonthDay(Date.now()); // 頁首日期=台北日,不吃裝置時區
$('ck-date').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
fetch('/console/dashboard-data')
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (d) {
@@ -749,18 +766,27 @@ function renderConsoleHtml(registryBase: string): string {
} else {
wmeta.textContent = '';
}
// 今日路線:非今日寫入=誠實降級為「最後路線(N 天前)」,不假裝是今天的
// 今日路線:sprint 任務板優先(來源與更新時間攤開講);dash_task fallback 沿舊誠實降級
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'; });
if (rm.is_today || !today.length) {
if (rm.source === 'gitea_sprint_board') {
$('ck-route-src').textContent = '來源 sprint 任務板・更新於 ' + ckAge(rm.updated_ago_minutes);
var staleHead = rm.is_today ? '' :
'<li class="todo"><span class="ic">○</span><span class="muted">⚠ 今日任務板未更新(最後 ' + ckAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';
$('ck-today').innerHTML = staleHead + (today.length
? today.map(taskLine).join('')
: '<li class="todo"><span class="ic">○</span><span class="muted">任務板上沒有可解析的事項</span></li>');
} else if (rm.is_today || !today.length) {
$('ck-route-src').textContent = '來源 dash_task';
$('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-route-src').textContent = '來源 dash_task';
$('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 });
$('ck-stamp').textContent = '每 60 秒自動刷新・上次 ' + taipeiTimeStr(Date.parse(d.generated_at)) + '(台北)';
})
.catch(function (e) {
$('ck-char').textContent = '';
@@ -785,7 +811,7 @@ function renderConsoleHtml(registryBase: string): string {
if (searchInited) { return; }
searchInited = true;
renderChips();
ensureTotal();
loadScale();
}
function renderChips() {
$('se-chips').innerHTML = CHIPS.map(function (c, i) {
@@ -799,15 +825,18 @@ function renderConsoleHtml(registryBase: string): string {
renderChips();
if ($('se-q').value.trim()) doSearch();
});
function ensureTotal() {
fetch('/kbdb/entries?limit=1', { headers: apiHeaders() })
// 頭部統計=精耕層 live 數字(leo 2026-07-07 裁:45.8 萬 14-E 搬遷遺產已 deprecated、
// 之後要刪——不再拿遺產總數撐場面,只顯示真的新的)。搜尋本身仍搜全庫,資料不藏。
function loadScale() {
fetch('/console/kb-scale-data')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && typeof d.total === 'number') {
S.total = d.total;
$('se-scale').textContent = '總庫 ' + d.total.toLocaleString() + ' 筆';
$('se-q').placeholder = '搜尋 ' + d.total.toLocaleString() + ' 筆知識…';
}
if (!d) return;
var n = function (v) { return v == null ? '' : v.toLocaleString(); };
var parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)];
var latest = d.wiki_card_latest_ago_minutes;
$('se-scale').textContent = '精耕層 ' + parts.join('・') +
(latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');
})
.catch(function () { /* 規模感拿不到不擋搜尋 */ });
}
@@ -848,17 +877,21 @@ function renderConsoleHtml(registryBase: string): string {
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋尚未啟用</div><div class="b">語意搜尋用「意思」找資料,不是字面比對。<br>' + esc(d.capability_hint || '部署端尚未開啟 Vectorize——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';
}
var entries = d.entries || [];
var scale = S.total != null ? '於 ' + S.total.toLocaleString() + ' 筆中' : '';
$('se-count').textContent = scale + '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword');
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') + '・搜尋範圍=全庫(含 14-E 遺產)';
if (!entries.length) {
$('se-results').innerHTML = '<div class="muted" style="padding:30px 10px;text-align:center;grid-column:1/-1">找不到「' + esc(q) + '」——換個關鍵字試試。</div>';
return;
}
$('se-results').innerHTML = entries.map(function (e) {
// 精耕層(wiki_card)綠標突顯;其餘照 entry_type 原樣(不誤標——block/value/note
// 大宗是 14-E 遺產,但 entry_type 本身不足以斷言,僅精耕層正向標記)
var typeTag = e.entry_type === 'wiki_card'
? '<span class="tag green">wiki_card・精耕</span>'
: '<span class="tag">' + esc(e.entry_type || 'entry') + '</span>';
return '<div class="kcard" data-card="' + esc(e.id) + '">' +
'<div class="kt">' + esc(entryTitle(e)) + '</div>' +
'<div class="ks">' + esc(entrySnippet(e)) + '</div>' +
'<div class="km"><span class="tag">' + esc(e.entry_type || 'entry') + '</span>' +
'<div class="km">' + typeTag +
(e.source ? '<span class="dim">' + esc(e.source) + '</span>' : '') +
'<span class="dim mono" style="margin-left:auto">' + esc(fmtDate(e.created_at)) + '</span></div></div>';
}).join('');
@@ -1158,8 +1191,8 @@ function renderConsoleHtml(registryBase: string): string {
'<div class="rowhead" data-credtoggle="' + esc(c.name) + '">' +
'<span class="avatar">' + esc(initial) + '</span>' +
'<div style="min-width:0"><div style="font-size:16.5px;font-weight:600;word-break:break-all">' + esc(c.name) + '</div>' +
'<div style="margin-top:2px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)">' + esc(c.service || '未填服務') + '・建立於 ' + esc(fmtDate(c.created_at) || '—') + '</div></div>' +
'<div style="margin-left:auto;text-align:right;font-size:12.5px;color:rgba(var(--ink-rgb),.4);flex:none">最後使用<br>' + esc(c.last_used_at ? fmtDate(c.last_used_at) : '尚未記錄') + '</div></div>' +
'<div style="margin-top:2px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)">' + esc(c.service || '未填服務') + '・建立於 ' + esc(fmtDateTime(c.created_at) || '—') + '(台北)</div></div>' +
'<div style="margin-left:auto;text-align:right;font-size:12.5px;color:rgba(var(--ink-rgb),.4);flex:none">最後使用<br>' + esc(c.last_used_at ? fmtDateTime(c.last_used_at) : '尚未記錄') + '</div></div>' +
(open
? '<div class="rowbody">' +
'<div class="maskline" style="margin-top:14px"><span class="mono" style="font-size:16px;letter-spacing:.3em;color:rgba(var(--ink-rgb),.4)">••••••••••••••••</span><span style="margin-left:auto;font-size:12.5px;color:rgba(var(--ink-rgb),.4)">已加密・不可回看</span></div>' +
@@ -1257,21 +1290,36 @@ function renderConsoleHtml(registryBase: string): string {
$('st-vec').textContent = on
? '已啟用・語意搜尋可用(搜尋頁切「語意」)'
: '未啟用・' + (d.capability_hint || '部署端尚未開啟 Vectorizekbdb_embed');
$('st-vec-state').textContent = on ? '已啟用(live 探測 mode=semantic' : '未啟用(live 探測降級 keyword';
})
.catch(function () { $('st-vec').textContent = '狀態偵測失敗(KBDB 不可達)'; });
// 系統資訊:worker 自報 + 誠實 total
.catch(function () {
$('st-vec').textContent = '狀態偵測失敗(KBDB 不可達)';
$('st-vec-state').textContent = '偵測失敗';
});
// MCP token TTL(誠實佔位:只顯示目前生效值,不假裝能改)
fetch('/console/settings-data')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) { $('st-mcp-ttl').textContent = '讀取失敗'; return; }
var days = Math.round(d.mcp_token_ttl_seconds / 86400 * 10) / 10;
$('st-mcp-ttl').textContent = '目前生效:' + d.mcp_token_ttl_seconds.toLocaleString() + ' 秒(約 ' + days + ' 天)・' +
(d.mcp_token_ttl_source === 'env' ? '來自部署端 env MCP_TOKEN_TTL' : '未設 env,用預設值 30 天');
})
.catch(function () { $('st-mcp-ttl').textContent = '讀取失敗'; });
// 系統資訊:worker 自報 + 精耕層規模(14-E 遺產總數不再顯示,leo 2026-07-07 裁)
Promise.allSettled([
fetch('/').then(function (r) { return r.json(); }),
fetch('/kbdb/entries?limit=1', { headers: apiHeaders() }).then(function (r) { return r.ok ? r.json() : null; })
fetch('/console/kb-scale-data').then(function (r) { return r.ok ? r.json() : null; })
]).then(function (rs) {
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;
var n = function (v) { return v == null ? '' : v.toLocaleString(); };
var rows = '';
rows += '<div class="kvline"><span class="muted">服務</span><span class="mono" style="font-size:14px">' + esc(svc.service || 'arcrun-cypher-executor') + '</span></div>';
rows += '<div class="kvline"><span class="muted">版本</span><span class="mono" style="color:var(--amber)">' + esc(svc.version || '—') + '</span></div>';
rows += '<div class="kvline"><span class="muted">部署位置</span><span class="mono" style="font-size:14px">' + esc(location.host) + '</span></div>';
rows += '<div class="kvline"><span class="muted">租戶</span><span class="mono">' + esc(S.tenant) + '</span></div>';
rows += '<div class="kvline"><span class="muted">總庫規模</span><span class="mono" style="color:var(--amber)">' + (kb && typeof kb.total === 'number' ? kb.total.toLocaleString() + ' 筆' : '讀取失敗') + '</span></div>';
rows += '<div class="kvline"><span class="muted">精耕層</span><span class="mono" style="color:var(--amber)">' + (kb ? 'wiki 卡 ' + n(kb.wiki_card_total) + '・三元組 ' + n(kb.triplets_total) + '・已嵌入 ' + n(kb.embedded) : '讀取失敗') + '</span></div>';
$('st-info').innerHTML = rows;
});
}
@@ -1297,7 +1345,7 @@ function renderConsoleHtml(registryBase: string): string {
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
});
$('st-vec-switch').addEventListener('click', function () {
toast('開關即將開通——目前需在部署端啟用 kbdb_embed');
toast('開關不能遠端改——部署端 config.yaml 開 kbdb_embed 後 acr update 重部署');
});
$('st-logout').addEventListener('click', function () {
var t = S.token;
+5
View File
@@ -87,6 +87,11 @@ export type Bindings = {
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
// 真相住在 mcp worker 的同名 envmcp/src/types.ts,預設 259200030 天);cypher 這份
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
// 可調功能(設定頁改→存 KBDB→發 token 時讀)=Arcrun#19,實作前先在部署端 env 調。
MCP_TOKEN_TTL?: string;
// 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)。
@@ -1,16 +1,17 @@
/**
* cachedGiteaWaiting 快取層測試(總管 #36 審查要求的證據)。
* cachedGiteaSprint 快取層測試(總管 #36 審查要求的證據fix/console-truth-audit 起
* 同一輪快取多帶任務板 board——「等你的事」與「今日完成」共用同一次 Gitea fetch)。
*
* 跑在 vitest-pool-workersworkerd)裡,caches.default 是真的 Cache API——
* 不是 mock cache 物件;fetcher 用注入的假函式計數,證明:
* 1. 第一次 miss(打 fetcher)→ 90s 內第二次 hitfetcher 不再被打)
* 1. 第一次 miss(打 fetcher)→ 90s 內第二次 hitfetcher 不再被打)board 一起回放
* 2. hit 回放時 age 隨牆鐘推進(不回放停走的時間)
* 3. fetcher 失敗(回 null)不快取——下次仍會重試,不把抖動放大成 90 秒盲區
* 4. 缺 GITEA_BASE_URL/GITEA_TOKEN 直接 null(快取層不啟動)
*/
import { describe, it, expect } from 'vitest';
import { cachedGiteaWaiting } from '../src/routes/console-dashboard';
import type { WaitingModel } from '../src/lib/console-dashboard-model';
import { cachedGiteaSprint } from '../src/routes/console-dashboard';
import type { SprintSnapshot } from '../src/lib/console-dashboard-model';
import type { Bindings } from '../src/types';
function fakeEnv(repo: string): Bindings {
@@ -22,13 +23,19 @@ function fakeEnv(repo: string): Bindings {
} as unknown as Bindings;
}
function model(): WaitingModel {
function snapshot(): SprintSnapshot {
return {
items: [{ title: 'mira .env 歷史憑證外洩', id: '13', urgency: '🔴', sprint: 'sprint-2026-07a.md' }],
source: 'gitea_sprint',
updated_ago_minutes: 10,
stale: false,
sprint_files: ['sprint-2026-07b.md', 'sprint-2026-07a.md'],
waiting: {
items: [{ title: 'mira .env 歷史憑證外洩', id: '13', urgency: '🔴', sprint: 'sprint-2026-07a.md' }],
source: 'gitea_sprint',
updated_ago_minutes: 10,
stale: false,
sprint_files: ['sprint-2026-07b.md', 'sprint-2026-07a.md'],
},
board: [
{ title: 'T5-graphGraph 查詢 workflow 化)', status: 'todo', completed_days: [], sprint: 'sprint-2026-07b.md' },
{ title: 'T-kb2 07-07', status: 'done', completed_days: ['2026-07-05'], sprint: 'sprint-2026-07a.md' },
],
};
}
@@ -38,37 +45,40 @@ function collector() {
return { waitUntil: (p: Promise<unknown>) => pending.push(p), flush: () => Promise.all(pending) };
}
describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s', () => {
it('第一次 miss 打 fetcher90s 內第二次 hit、fetcher 不再被打', async () => {
describe('cachedGiteaSprint — Gitea 讀取快取(TTL 90swaiting + board 同輪', () => {
it('第一次 miss 打 fetcher90s 內第二次 hit、fetcher 不再被打board 一起回放', async () => {
const env = fakeEnv('Leo/cache-test-1');
let calls = 0;
const fetcher = async () => {
calls++;
return model();
return snapshot();
};
const c = collector();
const t0 = Date.now();
const r1 = await cachedGiteaWaiting(env, t0, c.waitUntil, fetcher);
const r1 = await cachedGiteaSprint(env, t0, c.waitUntil, fetcher);
expect(r1?.cache).toBe('miss');
expect(calls).toBe(1);
await c.flush(); // cache.put 落地
const r2 = await cachedGiteaWaiting(env, t0 + 60_000, c.waitUntil, fetcher);
const r2 = await cachedGiteaSprint(env, t0 + 60_000, c.waitUntil, fetcher);
expect(r2?.cache).toBe('hit'); // ← 快取生效的客觀證據
expect(calls).toBe(1); // fetcher 沒有被第二次呼叫=Gitea 沒被打
expect(r2?.items[0].id).toBe('13');
expect(r2?.waiting.items[0].id).toBe('13');
// 任務板同輪回放:存的是原始 completed_days(「今天完成幾件」由請求當下算,跨午夜不冒領)
expect(r2?.board?.length).toBe(2);
expect(r2?.board?.[1].completed_days).toEqual(['2026-07-05']);
});
it('hit 回放時 age 隨牆鐘推進(存 10 分鐘前、過 60s 讀 → 11 分鐘前),不停走', async () => {
const env = fakeEnv('Leo/cache-test-2');
const c = collector();
const t0 = Date.now();
await cachedGiteaWaiting(env, t0, c.waitUntil, async () => model());
await cachedGiteaSprint(env, t0, c.waitUntil, async () => snapshot());
await c.flush();
const r = await cachedGiteaWaiting(env, t0 + 60_000, c.waitUntil, async () => model());
const r = await cachedGiteaSprint(env, t0 + 60_000, c.waitUntil, async () => snapshot());
expect(r?.cache).toBe('hit');
expect(r?.updated_ago_minutes).toBe(11); // 10 + 1 分鐘 drift
expect(r?.waiting.updated_ago_minutes).toBe(11); // 10 + 1 分鐘 drift
});
it('fetcher 失敗(null)不快取:下一次仍重打 fetcher', async () => {
@@ -79,9 +89,9 @@ describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s', () => {
calls++;
return null;
};
expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, failing)).toBeNull();
expect(await cachedGiteaSprint(env, Date.now(), c.waitUntil, failing)).toBeNull();
await c.flush();
expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, failing)).toBeNull();
expect(await cachedGiteaSprint(env, Date.now(), c.waitUntil, failing)).toBeNull();
expect(calls).toBe(2); // 失敗沒有被 90 秒快取住
});
@@ -90,10 +100,10 @@ describe('cachedGiteaWaiting — Gitea 讀取快取(TTL 90s', () => {
let calls = 0;
const fetcher = async () => {
calls++;
return model();
return snapshot();
};
const c = collector();
expect(await cachedGiteaWaiting(env, Date.now(), c.waitUntil, fetcher)).toBeNull();
expect(await cachedGiteaSprint(env, Date.now(), c.waitUntil, fetcher)).toBeNull();
expect(calls).toBe(0);
});
});
@@ -192,3 +192,98 @@ describe('pickLatestSprintFiles / humanAge', () => {
expect(humanAge(3 * 24 * 60)).toBe('3 天前');
});
});
// ── sprint 任務板(「今日完成」活資料源,fix/console-truth-audit)────────────────────
// 樣本取自 sprint-2026-07a/07b.md 真實維護慣例:`- [x]`+行內「**完成(YYYY-MM-DD…)**」、
// `- [🔄]` 進行中、`- [!]` 卡住、縮排子彈(「 - 做法:」)非任務、一行多次完成(T6-T9 分兩天)。
import {
parseSprintTaskBoard,
buildSprintRouteModel,
cleanBoardTitle,
} from '../src/lib/console-dashboard-model';
const BOARD_MD = `# Sprint 2026-07x
## P1 > P2 > P3worker
### P1
- [ ] **T5-graphGraph workflow ** neighbors/traverse workflow
- \`Leo/arcrun-rag\` 建 workflows/graph-neighbors.yaml
- curl
- [🔄] **T-cockpit **2026-07-04[] spike
- [x] RECONgit proxy Gitea06-30
- [x] **T-loop1d ** console v0**2026-07-02[cloud-worker]**
- [x] T6-T907-06~08 **T8/T9 2026-07-04commit 1d19d46****T6/T7 2026-07-05 **
- [!] acr search npm **2026-07-05[cloud-worker]** NPM_API_TOKEN
### sprint
- content-pipeline.md
## leo
| # | | |
|---|------|------|
| 1 | | 🟡 |
`;
describe('parseSprintTaskBoard — 任務板解析(寬鬆容錯,解析不了寧可略過)', () => {
it('只收行首 checkbox;縮排子彈/候補區敘述/等leo清單表格都不是任務', () => {
const tasks = parseSprintTaskBoard(BOARD_MD, 'sprint-2026-07x.md');
expect(tasks).not.toBeNull();
expect(tasks!.map((t) => t.status)).toEqual(['todo', 'doing', 'done', 'done', 'done', 'blocked']);
expect(tasks!.every((t) => t.sprint === 'sprint-2026-07x.md')).toBe(true);
});
it('完成日期:抓行內所有「完成(YYYY-MM-DD」;[x] 無日期=空陣列(不冒領)', () => {
const tasks = parseSprintTaskBoard(BOARD_MD)!;
expect(tasks[2].completed_days).toEqual([]); // RECON:勾了但沒帶日期 → 不歸任何一天
expect(tasks[3].completed_days).toEqual(['2026-07-02']);
expect(tasks[4].completed_days).toEqual(['2026-07-04', '2026-07-05']); // 一行兩次完成
expect(tasks[1].completed_days).toEqual(['2026-07-04']); // doing 項的部分完成標記也記著(狀態仍 doing)
});
it('標題:優先取第一個粗體;無粗體取全形冒號前', () => {
const tasks = parseSprintTaskBoard(BOARD_MD)!;
expect(tasks[0].title).toBe('T5-graphGraph 查詢 workflow 化)');
expect(tasks[2].title).toBe('RECON');
expect(cleanBoardTitle('附帶:`acr search` npm 漏發')).toBe('附帶:acr search npm 漏發'.slice(0, 80));
});
it('沒有任務板段落 → nullcaller 誠實 fallback dash_task', () => {
expect(parseSprintTaskBoard('# 只有標題\n\n## 等 leo 清單\n| 1 | x | 🔴 |')).toBeNull();
});
});
describe('buildSprintRouteModel — 「今日完成」只認今天(台北)的完成標記', () => {
it('NOW=07-0707-04/07-05 完成的不算今日;開項全列(今日路線)', () => {
const tasks = parseSprintTaskBoard(BOARD_MD)!;
const m = buildSprintRouteModel(tasks, NOW);
expect(m.today_done).toBe(0); // 板上今天沒勾任何完成 → 誠實 0
expect(m.done_today_titles).toEqual([]);
// 開項:todo + doing + blocked 3today_total 0 完成 + 3 開
expect(m.today_total).toBe(3);
expect(m.tasks.map((t) => t.status)).toEqual(['todo', 'doing', 'blocked']);
});
it('完成標記日期=今天 → 進今日完成清單;歷史完成項不出現', () => {
const tasks = parseSprintTaskBoard(BOARD_MD)!;
// 模擬 progress-guard 今天勾掉 T5-graph
tasks[0] = { ...tasks[0], status: 'done', completed_days: [taipeiDayKey(NOW)] };
const m = buildSprintRouteModel(tasks, NOW);
expect(m.today_done).toBe(1);
expect(m.done_today_titles).toEqual(['T5-graphGraph 查詢 workflow 化)']);
expect(m.today_total).toBe(1 + 2); // 1 完成 + doing/blocked 2 開項
expect(m.tasks[0].status).toBe('done'); // 今日完成排最前(✓ 給 leo 看「做了什麼」)
// 07-02/07-04/07-05 完成的三項(含無日期 RECON)都不在清單裡
expect(m.tasks.find((t) => t.title === 'RECON')).toBeUndefined();
});
it('跨台北午夜:昨天的完成不會被冒領成今天(快取安全性的模型面保證)', () => {
const tasks = parseSprintTaskBoard(BOARD_MD)!;
tasks[0] = { ...tasks[0], status: 'done', completed_days: ['2026-07-07'] };
// 台北 07-08 00:10UTC 07-07 16:10)再看:07-07 的完成已不是「今日」
const afterMidnight = Date.parse('2026-07-07T16:10:00Z');
expect(buildSprintRouteModel(tasks, afterMidnight).today_done).toBe(0);
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* lib/taipei-time.ts fix/console-truth-audit
* 1. server UTC bug
* 2. client TAIPEI_CLIENT_JS console ES5 new Function
* server helper
* 3. leo leo21c /credentials gitea_token created_at=1783349834epoch
* 2026-07-06 22:57 7/6
*/
import { describe, it, expect } from 'vitest';
import {
TAIPEI_CLIENT_JS,
taipeiDayKey,
taipeiDateStr,
taipeiDateTimeStr,
taipeiTimeStr,
} from '../src/lib/taipei-time';
// client 版求值:跟頁面一樣把字串當 JS 跑起來,拿到同名函式
const client = new Function(
`${TAIPEI_CLIENT_JS}; return { taipeiDayKey, taipeiDateStr, taipeiDateTimeStr, taipeiTimeStr, taipeiMonthDay };`,
)() as {
taipeiDayKey: (ms: number) => string;
taipeiDateStr: (ms: number) => string;
taipeiDateTimeStr: (ms: number) => string;
taipeiTimeStr: (ms: number) => string;
taipeiMonthDay: (ms: number) => { month: number; day: number };
};
describe('taipei-time — server 版', () => {
it('UTC 16:30 已是台北隔天 00:30(跨日核心案例)', () => {
const ms = Date.parse('2026-07-06T16:30:00Z');
expect(taipeiDayKey(ms)).toBe('2026-07-07');
expect(taipeiDateTimeStr(ms)).toBe('2026-07-07 00:30');
});
it('UTC 15:59 仍是台北同日 23:59(跨日邊界的另一側)', () => {
const ms = Date.parse('2026-07-06T15:59:00Z');
expect(taipeiDayKey(ms)).toBe('2026-07-06');
expect(taipeiDateTimeStr(ms)).toBe('2026-07-06 23:59');
});
it('跨月/跨年也對:UTC 12-31 20:00 → 台北 01-01 04:00', () => {
const ms = Date.parse('2026-12-31T20:00:00Z');
expect(taipeiDateStr(ms)).toBe('2027-01-01');
expect(taipeiTimeStr(ms)).toBe('04:00:00');
});
it('leo 實測值:credentials created_at=1783349834epoch 秒)→ 台北 2026-07-06 22:57', () => {
expect(taipeiDateTimeStr(1783349834 * 1000)).toBe('2026-07-06 22:57');
// 同一時刻 UTC 是 14:57——若顯示端誤用 UTC 或別的裝置時區,日期/時間就會漂
expect(new Date(1783349834 * 1000).toISOString().slice(0, 16)).toBe('2026-07-06T14:57');
});
});
describe('taipei-time — client 版(TAIPEI_CLIENT_JS)與 server 版等價', () => {
const cases = [
Date.parse('2026-07-06T16:30:00Z'), // 跨日
Date.parse('2026-07-06T15:59:59Z'), // 跨日邊界前一秒
Date.parse('2026-12-31T20:00:00Z'), // 跨年
Date.parse('2026-02-28T17:00:00Z'), // 跨月(非閏年 2 月)
1783349834 * 1000, // leo 實測 credential created_at
0, // epoch 起點(1970-01-01 08:00 台北)
];
it('taipeiDayKey / DateStr / DateTimeStr / TimeStr 全案例一致', () => {
for (const ms of cases) {
expect(client.taipeiDayKey(ms)).toBe(taipeiDayKey(ms));
expect(client.taipeiDateStr(ms)).toBe(taipeiDateStr(ms));
expect(client.taipeiDateTimeStr(ms)).toBe(taipeiDateTimeStr(ms));
expect(client.taipeiTimeStr(ms)).toBe(taipeiTimeStr(ms));
}
});
it('taipeiMonthDay(頁首中文日期用):跨日案例回台北的月/日', () => {
expect(client.taipeiMonthDay(Date.parse('2026-07-06T16:30:00Z'))).toEqual({ month: 7, day: 7 });
expect(client.taipeiMonthDay(Date.parse('2026-12-31T20:00:00Z'))).toEqual({ month: 1, day: 1 });
});
});
@@ -21,6 +21,14 @@
- [x] 3.4 手工注入部署 leo21ccurl 驗證(禁 acr update
- [x] 3.5 SDD 補件(本目錄,issue #3 授權;Phase 1/2 追認)
## Phase 5 — console 真實性稽核修正(2026-07-07,總管派工+leo 裁決,fix/console-truth-audit
- [x] 5.1 全站日期顯示統一 Asia/Taipeilib/taipei-time.ts server/client 同套、等價單測;憑證頁加時分)
- [x] 5.2 頭部統計改精耕層(leo 裁:14-E 遺產 458,732 不再顯示;GET /console/kb-scale-data
- [x] 5.3 設定頁誠實文案升級(vectorize 開關真相+live 狀態;MCP token TTL 佔位=Arcrun#19 前置)
- [x] 5.4 駕駛艙「今日完成/今日路線」接 sprint 任務板(同 #36 等leo清單模式、共用 90s 快取;dash_task 降 fallbackparser 以 live 07a/07b 檔實測)
- [ ] 5.5 部署 leo21c+端到端 curl 驗證(不歸稽核輪——紅線不部署,留總管;證據標準同 3.4)
## Phase 4 — v1(未排)
- [ ] config 真寫入開關