fix(console): 駕駛艙「今日完成」跨 UTC 日界修正——07b 板實態 3 個 parser 落差(0/10 假空)
SDD:system-dev/docs/3-specs/arcrun/search-console/tasks.md task 5.6(本次新增,Phase 5 稽核輪延伸)
live bug(2026-07-08 21:40 台北實測):sprint-2026-07b.md 台北今晨 05:50
(=UTC 07-07 21:50,cloud-worker 跨 UTC 日界寫入)勾了 CP1/T7/T5-provider,
/console/dashboard-data 卻回 today_done:0/today_total:10、done_today_titles:[]。
根因=任務板 parser 與 07b 實際維護慣例的三個落差(lib/console-dashboard-model.ts):
1. COMPLETED_DAY 要求日期緊貼「完成(」——07b 起署名在日期前
(「完成([cloud-worker] 2026-07-07,…)」)→ T7 的完成日抓不到,
[x] 無日期=整項蒸發(不冒領原則誤傷)。
2. 完成標記寫在縮排子彈上(CP1/T5-provider 的任務行只有 [x])→ 舊 parser
只掃行首 checkbox 行 → 同樣蒸發。
3. cloud-worker sandbox 時鐘是 UTC、routine 排程台北清晨(06:30=UTC 前一日
22:30)——它戳的日期 D 其實是台北 D+1 凌晨的完成,todayKey 永遠對不上。
修法(全在純函式層,route 零改動):
- DONE_STAMP 改抓「完成(…)」括號內容、日期允許前綴;縮排補充行的完成
標記歸到所屬任務(子彈本身仍不是任務)。
- [cloud-worker] 署名戳 D 展開台北候選日 {D, D+1};progress-guard 跑台北
16:30(UTC 同日)不展開——展開只會多冒領;本機/總管台北戳照舊精確,
「昨天完成的明天不冒領」不變。
- 狀態欄放寬:[🔄→只剩1042](07b 實例)認 doing,任務不再蒸發、today_total
不再少算;x 帶註記仍不冒領 done(寬鬆容錯原則不變)。
驗證:新增 5 案例(07b 逐字真實形態)修前紅(today_done:0、done_today_titles
[]=live 症狀重現)修後綠;model 測試 27/27;全套 vitest 87/88(1 失敗=
pre-existing executor「不存在的零件」,stash 於未改樹複驗同樣紅);tsc 錯誤
行數與未改樹相同(12 行,全在 validate/webhooks-named,workers-types 環境
漂移,非本次引入);另用 Gitea live 07a+07b 檔直灌新 parser:today_done:3、
done_today_titles=[T5-provider, CP1, T7]=板上真相。不部署(留 task 5.5)。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -249,17 +249,63 @@ export function buildWaitingFallback(entries: KbdbEntry[], nowMs: number): Waiti
|
||||
|
||||
export interface SprintBoardTask {
|
||||
title: string;
|
||||
/** [x]=done、[🔄]=doing、[ ]=todo、[!]=blocked(板上維護慣例,對照 sprint-2026-07a.md) */
|
||||
/** [x]=done、[🔄]=doing、[ ]=todo、[!]=blocked(板上維護慣例,對照 sprint-2026-07a.md)。
|
||||
* 🔄/! 後帶註記也認(07b 實例 `[🔄→只剩1042]`);x 帶註記不冒領 done、整行略過。 */
|
||||
status: 'done' | 'doing' | 'todo' | 'blocked';
|
||||
/** 行內所有「完成(YYYY-MM-DD…)」標記的日期(一行可有多次完成,如 T6-T9 分兩天)。
|
||||
* [x] 但沒有帶日期的完成標記 → 空陣列=無法歸日,任何一天都不冒領(寧可略過不誤判)。 */
|
||||
/** 「完成(…YYYY-MM-DD…)」標記對應的**台北候選日**(一項可有多次完成,如 T6-T9 分兩天)。
|
||||
* [x] 但沒有帶日期的完成標記 → 空陣列=無法歸日,任何一天都不冒領(寧可略過不誤判)。
|
||||
* ⚠️ 跨 UTC 日界(fix/console-today-write-dayline,2026-07-08 live bug):cloud-worker
|
||||
* 的 sandbox 時鐘是 UTC、routine 排程在台北清晨(06:30=UTC 前一日 22:30)——它戳的
|
||||
* 日期 D 其實是台北 D+1 凌晨的完成。故帶 [cloud-worker] 署名的戳展開成 {D, D+1} 兩個
|
||||
* 候選日(不展開=今天凌晨完成的整天數不到=leo 實際撞到的 0/10);本機/總管戳台北日,
|
||||
* 照舊精確不展開。 */
|
||||
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;
|
||||
/** 狀態欄放寬到「任意非 ] 內容」再由 classifyBoardMark 分類——07b 實例 `[🔄→只剩1042]`
|
||||
* 用舊的單字元 regex 會讓整項任務蒸發(連 today_total 都少算)。 */
|
||||
const BOARD_LINE = /^- \[([^\]]*)\]\s*(.*)$/u;
|
||||
/** 完成標記的括號內容(到閉括號/行尾)。日期不再要求緊貼「完成(」——07a 慣例日期在前
|
||||
* (`完成(2026-07-02,[cloud-worker])`)、07b 起署名在前(`完成([cloud-worker]
|
||||
* 2026-07-07,…)`),舊 regex 只吃得下前者,T7 的完成日因此抓不到(live bug 根因 1)。 */
|
||||
const DONE_STAMP = /完成(([^)\n]*)/gu;
|
||||
const STAMP_DAY = /\d{4}-\d{2}-\d{2}/u;
|
||||
/** 時鐘走 UTC 且排程落在台北 00:00-08:00 窗(會跨日界)的雲端寫手署名。progress-guard
|
||||
* 跑台北 16:30(UTC 08:30,兩邊同日)不在此列——對它展開只會多冒領不會少算。 */
|
||||
const UTC_DAYLINE_ACTORS = /\[cloud-worker\]/u;
|
||||
|
||||
/** 台北日 key 的隔天('2026-07-07' → '2026-07-08')。 */
|
||||
export function nextDayKey(day: string): string {
|
||||
const ms = Date.parse(`${day}T00:00:00Z`);
|
||||
return Number.isNaN(ms) ? day : new Date(ms + 24 * 3600 * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** checkbox 狀態分類。null=看不懂的標記(寬鬆容錯:整行略過,尤其不冒領 done)。 */
|
||||
function classifyBoardMark(mark: string): SprintBoardTask['status'] | null {
|
||||
if (mark === ' ' || mark === '') return 'todo';
|
||||
if (mark === 'x' || mark === 'X') return 'done';
|
||||
if (mark.startsWith('🔄')) return 'doing';
|
||||
if (mark.startsWith('!')) return 'blocked';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 從一段文字抽完成標記的台北候選日(含 UTC 寫手的 {D, D+1} 展開,見 SprintBoardTask 註解)。 */
|
||||
export function extractCompletedDays(text: string): string[] {
|
||||
const days: string[] = [];
|
||||
const push = (d: string) => {
|
||||
if (!days.includes(d)) days.push(d);
|
||||
};
|
||||
for (const m of text.matchAll(DONE_STAMP)) {
|
||||
const content = m[1];
|
||||
const day = content.match(STAMP_DAY)?.[0];
|
||||
if (!day) continue;
|
||||
push(day);
|
||||
if (UTC_DAYLINE_ACTORS.test(content)) push(nextDayKey(day));
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/** 截斷/切冒號後可能留下未閉合的全形括號 → 從最後一個未配對「(」剪掉。
|
||||
* 整串都在括號裡(如「(T10 廢 ENCRYPTION_KEY:…」註記行)會剪成空字串——
|
||||
@@ -292,9 +338,12 @@ export function cleanBoardTitle(raw: string): string {
|
||||
|
||||
/**
|
||||
* 從 sprint md 撈「## 任務板」段(到下一個 `## ` 為止,`###` 子段照收),解析頂層
|
||||
* checkbox 事項。只認**行首**的 `- [x]` 型式——縮排子彈(「 - 做法:」)與無 checkbox
|
||||
* 的候補區純敘述行都不是任務,natural 濾掉。找不到任務板段落 → null(caller 誠實
|
||||
* fallback dash_task,不硬湊)。解析不了的行一律略過,不誤判成完成(寬鬆容錯原則)。
|
||||
* checkbox 事項。只有**行首**的 `- [x]` 型式會成為任務——縮排子彈(「 - 做法:」)與
|
||||
* 無 checkbox 的候補區純敘述行都不是任務;但縮排子彈上的**完成標記歸到所屬任務**
|
||||
* (07b 實際維護慣例:CP1/T5-provider 的任務行只有 [x],完成註記整段寫在子彈上——
|
||||
* 舊 parser 不掃子彈 → [x] 無日期=整項蒸發,live bug 根因 2)。找不到任務板段落 →
|
||||
* null(caller 誠實 fallback dash_task,不硬湊)。解析不了的行一律略過,不誤判成完成
|
||||
* (寬鬆容錯原則)。
|
||||
*/
|
||||
export function parseSprintTaskBoard(md: string, sprintFile?: string): SprintBoardTask[] | null {
|
||||
const secIdx = md.search(/^##\s*任務板/mu);
|
||||
@@ -302,22 +351,34 @@ export function parseSprintTaskBoard(md: string, sprintFile?: string): SprintBoa
|
||||
const section = md.slice(secIdx);
|
||||
const lines = section.split('\n');
|
||||
const tasks: SprintBoardTask[] = [];
|
||||
let current: SprintBoardTask | null = null; // 縮排子彈的完成標記要歸的任務
|
||||
for (const line of lines.slice(1)) {
|
||||
if (/^##\s/.test(line) && !/^###/.test(line)) break; // 下一個 H2 段落,任務板結束
|
||||
if (/^###\s/.test(line)) {
|
||||
current = null; // 新子段開始,別把後面的子彈掛到前段的任務
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
});
|
||||
if (m) {
|
||||
current = null;
|
||||
const status = classifyBoardMark(m[1]);
|
||||
const body = m[2];
|
||||
const title = cleanBoardTitle(body);
|
||||
if (status === null || !title) continue;
|
||||
current = { title, status, completed_days: extractCompletedDays(body), sprint: sprintFile };
|
||||
tasks.push(current);
|
||||
continue;
|
||||
}
|
||||
if (/^\s+\S/.test(line)) {
|
||||
// 縮排補充行:完成標記歸到所屬任務;行本身不是任務
|
||||
if (current) {
|
||||
for (const d of extractCompletedDays(line)) {
|
||||
if (!current.completed_days.includes(d)) current.completed_days.push(d);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line.trim() !== '') current = null; // 非縮排敘述行(候補區等)=脫離上一個任務
|
||||
}
|
||||
return tasks.length ? tasks : null;
|
||||
}
|
||||
@@ -332,8 +393,9 @@ export interface SprintRouteModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* 任務板 →「今日完成」模型:done 只數「完成標記日期=今天(台北)」的項;歷史完成項
|
||||
* (前幾天勾的)不進今日清單也不進計數——不冒領昨天的功。開著的項照板序全列(今日路線)。
|
||||
* 任務板 →「今日完成」模型:done 只數「台北候選完成日含今天」的項(candidate 日集合
|
||||
* 見 SprintBoardTask.completed_days 註解,UTC 寫手的跨日界已在 parse 側展開);歷史
|
||||
* 完成項(前幾天勾的)不進今日清單也不進計數——不冒領昨天的功。開著的項照板序全列。
|
||||
*/
|
||||
export function buildSprintRouteModel(tasks: SprintBoardTask[], nowMs: number): SprintRouteModel {
|
||||
const todayKey = taipeiDayKey(nowMs);
|
||||
|
||||
Reference in New Issue
Block a user