diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index a10c0d0..21a2f5a 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -122,6 +122,7 @@ - V10.235 補 PPT 視覺 QA stale recovery:背景狀態寫入 worker PID;若部署 reload 後舊 PID 已不存在,`/observability/ppt_audit/vision_status` 會自動把 running 轉為可診斷 error 並允許重新排入,避免人工清 runtime state。 - Phase 58 candidate queue writer post-write smoke:新增 `services/market_intel/candidate_queue_writer_postwrite_smoke.py`、POST `/api/market_intel/manual_sample_review/candidate_queue_writer_postwrite_smoke` 與 UI smoke 按鈕,依 transaction preview 的 dedupe key 只讀查詢 `market_alert_review_queue`,讓 CLI 真寫入後可驗證 row 是否存在;頁面預設 execute=false 不連 DB、不寫 queue、不 commit、不掛 scheduler;版本同步至 V10.236。 - Phase 59 candidate queue writer operator drill:新增 `services/market_intel/candidate_queue_writer_operator_drill.py`、POST `/api/market_intel/manual_sample_review/candidate_queue_writer_operator_drill` 與 UI drill 按鈕,組裝 reviewed sample、備份、read-only preflight、CLI writer、post-write smoke 的操作員順序;API/UI 不讀 approval token、不執行 CLI、不連 DB、不寫 queue、不 commit、不掛 scheduler;版本同步至 V10.237。 + - V10.238 補業績圖表 runtime QA 與分析 tabs 窄版修正:新增 `quick_review --sales-charts` 檢查 `/daily_sales`、`/growth_analysis` 的 Chart.js ready、可繪製資料集與 canvas 非空白;同時把分析報表 tabs 手機版改為自適應 grid,避免 Metabase/Grist 外部連結超出右側。 - Schema smoke:`tests/test_market_intel_skeleton.py` 檢查 `Base.metadata` 內含 ADR-035 八張 `market_*` tables。 - Desktop UI QA:本機只註冊 `market_intel_bp` 的 Flask harness 載入 `/market_intel`,確認 Phase 15、候選預覽、writer preview、安全 flags、點陣暖紙視覺正常,console error 0。 - API QA:`/api/market_intel/schema_smoke` 通過 7 張表與 `market_platforms` 必要欄位檢查;`/api/market_intel/platform_seed_writer_plan` 回傳 4 筆 dry-run upsert preview,`writes_executed=false`,四平台皆 `blocked_dry_run_only`。 diff --git a/config.py b/config.py index 766a787..869ea5d 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.237" +SYSTEM_VERSION = "V10.238" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/scripts/check_sales_charts_runtime.js b/scripts/check_sales_charts_runtime.js new file mode 100644 index 0000000..8f7cbeb --- /dev/null +++ b/scripts/check_sales_charts_runtime.js @@ -0,0 +1,321 @@ +#!/usr/bin/env node +/* + * Runtime chart guard for the sales analytics pages. + * + * It catches the failure mode where the page renders a chart shell but Chart.js + * never draws the real series. The check intentionally inspects both the + * Chart.js runtime objects and the actual canvas pixels. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const Module = require('module'); + +const DEFAULT_BASE_URL = 'http://127.0.0.1:5003'; +const DEFAULT_TIMEOUT_MS = 30000; +const DEFAULT_SETTLE_MS = 1800; + +const ROUTE_CONTRACTS = { + '/daily_sales': { + readyDataset: 'dailyCharts', + expectedCanvases: ['trendChart', 'dodChart', 'wowChart', 'top10Chart'], + }, + '/growth_analysis': { + readyDataset: 'growthCharts', + expectedCanvases: ['revenueChart', 'momChart', 'aovChart', 'marginChart'], + }, +}; + +function parseArgs(argv) { + const options = { + baseUrl: DEFAULT_BASE_URL, + routes: [], + timeoutMs: DEFAULT_TIMEOUT_MS, + settleMs: DEFAULT_SETTLE_MS, + json: false, + screenshotDir: '', + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--base-url') { + options.baseUrl = argv[++i]; + } else if (arg === '--route') { + options.routes.push(argv[++i]); + } else if (arg === '--routes') { + options.routes.push(...argv[++i].split(',').map((item) => item.trim()).filter(Boolean)); + } else if (arg === '--timeout') { + options.timeoutMs = Number(argv[++i]) * 1000; + } else if (arg === '--settle-ms') { + options.settleMs = Number(argv[++i]); + } else if (arg === '--screenshot-dir') { + options.screenshotDir = argv[++i]; + } else if (arg === '--json') { + options.json = true; + } else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (options.routes.length === 0) { + options.routes = Object.keys(ROUTE_CONTRACTS); + } + return options; +} + +function printHelp() { + console.log(`Sales charts runtime guard + +Options: + --base-url URL Base URL, default ${DEFAULT_BASE_URL} + --route PATH Add one route, can be repeated + --routes A,B,C Add comma-separated routes + --timeout SEC Navigation timeout, default 30 + --settle-ms MS Post-ready settle wait, default 1800 + --screenshot-dir DIR Save failure screenshots + --json Print JSON summary +`); +} + +function requirePlaywright() { + try { + return require('playwright'); + } catch (error) { + const candidates = [ + process.env.PLAYWRIGHT_NODE_MODULE_DIR, + process.env.NODE_PATH, + path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'), + ].filter(Boolean); + + for (const candidate of candidates) { + const packageJson = path.join(candidate, 'playwright', 'package.json'); + if (fs.existsSync(packageJson)) { + return Module.createRequire(packageJson)('playwright'); + } + } + + throw new Error('Cannot load playwright. Set NODE_PATH or PLAYWRIGHT_NODE_MODULE_DIR to a node_modules directory containing playwright.'); + } +} + +function findChromeExecutable() { + const candidates = [ + process.env.SALES_CHARTS_CHROME_PATH, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ].filter(Boolean); + return candidates.find((candidate) => fs.existsSync(candidate)) || ''; +} + +function routeToUrl(baseUrl, route) { + return new URL(route, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); +} + +function safeName(input) { + return input.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'root'; +} + +async function collectChartMetrics(page, contract) { + return page.evaluate(({ expectedCanvases, readyDataset }) => { + function countInkPixels(canvas) { + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context || !canvas.width || !canvas.height) return 0; + + const sampleWidth = Math.min(canvas.width, 900); + const sampleHeight = Math.min(canvas.height, 520); + const pixels = context.getImageData(0, 0, sampleWidth, sampleHeight).data; + let ink = 0; + for (let i = 0; i < pixels.length; i += 4) { + const alpha = pixels[i + 3]; + if (alpha > 10) { + ink += 1; + if (ink > 250) break; + } + } + return ink; + } + + function datasetPointCount(chart) { + if (!chart || !chart.data || !Array.isArray(chart.data.datasets)) return 0; + return chart.data.datasets.reduce((total, dataset) => { + const data = Array.isArray(dataset.data) ? dataset.data : []; + return total + data.filter((value) => value !== null && value !== undefined && Number.isFinite(Number(value))).length; + }, 0); + } + + function visibleElementCount(chart) { + if (!chart || typeof chart.getSortedVisibleDatasetMetas !== 'function') return 0; + return chart.getSortedVisibleDatasetMetas().reduce((total, meta) => { + if (!meta || !Array.isArray(meta.data)) return total; + return total + meta.data.filter((item) => { + if (!item) return false; + const props = typeof item.getProps === 'function' + ? item.getProps(['x', 'y', 'base'], true) + : item; + return Number.isFinite(Number(props.x)) && Number.isFinite(Number(props.y)); + }).length; + }, 0); + } + + const chartGetter = window.Chart && typeof window.Chart.getChart === 'function' + ? window.Chart.getChart.bind(window.Chart) + : () => null; + + const canvases = expectedCanvases.map((id) => { + const canvas = document.getElementById(id); + if (!canvas) return { id, exists: false }; + + const rect = canvas.getBoundingClientRect(); + const chart = chartGetter(canvas); + return { + id, + exists: true, + cssWidth: Math.round(rect.width), + cssHeight: Math.round(rect.height), + width: canvas.width, + height: canvas.height, + inkPixels: countInkPixels(canvas), + hasChart: Boolean(chart), + datasetCount: chart && chart.data && Array.isArray(chart.data.datasets) ? chart.data.datasets.length : 0, + dataPoints: datasetPointCount(chart), + visibleElements: visibleElementCount(chart), + ariaHidden: canvas.getAttribute('aria-hidden') || '', + }; + }); + + return { + finalUrl: location.href, + title: document.title, + login: Boolean(document.querySelector('form[action="/login"]')), + readyState: document.documentElement.dataset[readyDataset] || '', + chartVersion: window.Chart && window.Chart.version ? window.Chart.version : '', + canvases, + }; + }, { expectedCanvases: contract.expectedCanvases, readyDataset: contract.readyDataset }); +} + +function evaluateMetrics(route, status, metrics, consoleErrors) { + const failures = []; + if (!status || status >= 400) failures.push(`HTTP ${status || 0}`); + if (metrics.login) failures.push('redirected to login'); + if (metrics.readyState !== 'ready') failures.push(`chart boot state is ${metrics.readyState || 'missing'}`); + if (!metrics.chartVersion) failures.push('Chart.js runtime missing'); + for (const item of metrics.canvases) { + if (!item.exists) { + failures.push(`${item.id} missing`); + continue; + } + if (item.cssWidth < 220 || item.cssHeight < 180) { + failures.push(`${item.id} too small ${item.cssWidth}x${item.cssHeight}`); + } + if (!item.hasChart) failures.push(`${item.id} has no Chart.js instance`); + if (item.datasetCount <= 0 || item.dataPoints <= 0) { + failures.push(`${item.id} has no drawable dataset`); + } + if (item.visibleElements <= 0) failures.push(`${item.id} has no visible chart elements`); + if (item.inkPixels <= 250) failures.push(`${item.id} canvas appears blank`); + } + for (const err of consoleErrors) { + failures.push(`console ${err}`); + } + return { + route, + status, + passed: failures.length === 0, + failures, + metrics, + }; +} + +async function inspectRoute(page, options, route) { + const contract = ROUTE_CONTRACTS[route]; + if (!contract) throw new Error(`No chart contract for route: ${route}`); + + const consoleErrors = []; + const onConsole = (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }; + const onPageError = (error) => consoleErrors.push(error.message || String(error)); + page.on('console', onConsole); + page.on('pageerror', onPageError); + + try { + const response = await page.goto(routeToUrl(options.baseUrl, route), { + waitUntil: 'domcontentloaded', + timeout: options.timeoutMs, + }); + await page.waitForFunction( + (readyDataset) => document.documentElement.dataset[readyDataset] === 'ready', + contract.readyDataset, + { timeout: Math.min(options.timeoutMs, 15000) }, + ).catch(() => null); + if (options.settleMs > 0) await page.waitForTimeout(options.settleMs); + + const metrics = await collectChartMetrics(page, contract); + return evaluateMetrics(route, response ? response.status() : 0, metrics, consoleErrors); + } finally { + page.off('console', onConsole); + page.off('pageerror', onPageError); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const { chromium } = requirePlaywright(); + const launchOptions = { headless: true }; + const chromePath = findChromeExecutable(); + if (chromePath) launchOptions.executablePath = chromePath; + + if (options.screenshotDir) fs.mkdirSync(options.screenshotDir, { recursive: true }); + + const browser = await chromium.launch(launchOptions); + const page = await browser.newPage({ + viewport: { width: 1440, height: 1000 }, + deviceScaleFactor: 1, + }); + const results = []; + + try { + for (const route of options.routes) { + const result = await inspectRoute(page, options, route); + results.push(result); + if (!result.passed && options.screenshotDir) { + await page.screenshot({ + path: path.join(options.screenshotDir, `${safeName(route)}.png`), + fullPage: false, + }); + } + } + } finally { + await page.close().catch(() => {}); + await browser.close(); + } + + if (options.json) { + console.log(JSON.stringify(results, null, 2)); + } else { + for (const result of results) { + const canvasSummary = result.metrics.canvases + .map((item) => `${item.id}:chart=${item.hasChart ? 'yes' : 'no'},points=${item.dataPoints || 0},ink=${item.inkPixels || 0}`) + .join(' '); + console.log(`${result.passed ? 'PASS' : 'FAIL'} ${result.route} status=${result.status} ${canvasSummary}`); + for (const failure of result.failures) { + console.log(` ${failure}`); + } + } + } + + if (results.some((result) => !result.passed)) { + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(error.message || error); + process.exit(1); +}); diff --git a/scripts/check_sales_charts_runtime.sh b/scripts/check_sales_charts_runtime.sh new file mode 100755 index 0000000..51a1bfe --- /dev/null +++ b/scripts/check_sales_charts_runtime.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUNDLED_NODE="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" +BUNDLED_NODE_MODULES="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules" + +if [ -z "${NODE_BIN:-}" ] && [ -x "$BUNDLED_NODE" ]; then + NODE_BIN="$BUNDLED_NODE" +fi + +if [ -z "${NODE_BIN:-}" ]; then + NODE_BIN="node" +fi + +if [ -d "$BUNDLED_NODE_MODULES" ] && [ -z "${NODE_PATH:-}" ]; then + export NODE_PATH="$BUNDLED_NODE_MODULES" +fi + +exec "$NODE_BIN" "$PROJECT_ROOT/scripts/check_sales_charts_runtime.js" "$@" diff --git a/scripts/quick_review.sh b/scripts/quick_review.sh index f9ff704..594a5df 100755 --- a/scripts/quick_review.sh +++ b/scripts/quick_review.sh @@ -22,6 +22,7 @@ OBSERVABILITY_QA_SUITE="$PROJECT_ROOT/scripts/check_observability_suite.sh" OBSERVABILITY_CSS_SYNC="$PROJECT_ROOT/scripts/sync_observability_css.py" OBSERVABILITY_VISUAL_CONTRACT="$PROJECT_ROOT/scripts/check_observability_visual_contract.sh" RESPONSIVE_OVERFLOW_GUARD="$PROJECT_ROOT/scripts/check_responsive_overflow.sh" +SALES_CHARTS_GUARD="$PROJECT_ROOT/scripts/check_sales_charts_runtime.sh" REVIEW_REPORT_HINT=0 # 顯示標題 @@ -109,6 +110,16 @@ run_responsive_overflow_guard() { bash "$RESPONSIVE_OVERFLOW_GUARD" "$@" } +run_sales_charts_guard() { + if [ ! -f "$SALES_CHARTS_GUARD" ]; then + echo -e "${RED}❌ 業績圖表 runtime guard 不存在: $SALES_CHARTS_GUARD${NC}" + exit 1 + fi + + echo -e "${GREEN}📈 開始業績分析圖表 runtime 檢查...${NC}" + bash "$SALES_CHARTS_GUARD" "$@" +} + # 非互動入口:給部署腳本、CI、或 Codex session 直接呼叫。 if [ $# -gt 0 ]; then case "$1" in @@ -147,6 +158,11 @@ if [ $# -gt 0 ]; then run_responsive_overflow_guard "$@" exit $? ;; + --sales-charts) + shift + run_sales_charts_guard "$@" + exit $? + ;; --observability-help) cat <<'EOF' AI observability quick-review flags: @@ -164,6 +180,8 @@ AI observability quick-review flags: Run rendered typography, surface, radius, contrast, and mobile density checks. --responsive-overflow [--base-url URL] [--route PATH ...] Run desktop/tablet/mobile body horizontal overflow checks for Flask routes. + --sales-charts [--base-url URL] [--timeout SEC] + Run Chart.js runtime and nonblank canvas checks for /daily_sales and /growth_analysis. EOF exit 0 ;; @@ -184,8 +202,9 @@ if [ $# -eq 0 ]; then echo "9) 同步 AI觀測台 CSS static mirror" echo "10) 全頁 responsive overflow 檢查" echo "11) AI觀測台渲染後視覺契約檢查" + echo "12) 業績分析圖表 runtime 檢查" echo "" - read -p "請輸入選項 (1-11): " choice + read -p "請輸入選項 (1-12): " choice case $choice in 1) @@ -238,6 +257,9 @@ if [ $# -eq 0 ]; then 11) run_observability_visual_contract ;; + 12) + run_sales_charts_guard + ;; *) echo -e "${RED}❌ 無效選項${NC}" exit 1 diff --git a/web/static/css/analysis-report-tabs.css b/web/static/css/analysis-report-tabs.css index 6eb424e..47ad70f 100644 --- a/web/static/css/analysis-report-tabs.css +++ b/web/static/css/analysis-report-tabs.css @@ -58,9 +58,9 @@ @media (max-width: 720px) { .analysis-report-tabs { - flex-wrap: nowrap; - overflow-x: auto; - scrollbar-width: thin; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); + align-items: stretch; } .analysis-report-tabs-spacer { @@ -68,6 +68,8 @@ } .analysis-report-tab { - flex: 0 0 auto; + justify-content: center; + min-width: 0; + padding: 0 var(--momo-space-2, 8px); } }