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:
@@ -16,6 +16,12 @@
|
||||
* → 燈號只吃「今日寫入」的任務 + 心跳(心跳 dash_beat 是唯一有活管線的 dash_* 資料)。
|
||||
*/
|
||||
|
||||
import { taipeiDayKey } from './taipei-time';
|
||||
|
||||
// 台北日曆日 key 的唯一實作搬到 lib/taipei-time.ts(fix/console-truth-audit:console 全站
|
||||
// 日期顯示統一台北,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Asia/Taipei(UTC+8,無日光節約)時間顯示 helper(fix/console-truth-audit,2026-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');
|
||||
Reference in New Issue
Block a user