{
  "sha": "4ed4256904c10d238d4ecd1f168c3e764c37ae35",
  "message": "首頁本週嚴重事件可點擊並列出對應事件 (#131)\n\n將首頁統計卡連到事故資料庫本週篩選，並補上事件文章連結、中英文介面、手機版排列及回歸測試。",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-08-20T18:35:11Z",
  "files": [
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 32,
      "deletions": 5,
      "changes": 37
    },
    {
      "filename": "static/app.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 25,
      "deletions": 9,
      "changes": 34
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 18,
      "deletions": 0,
      "changes": 18
    },
    {
      "filename": "templates/home.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 2,
      "changes": 4
    },
    {
      "filename": "templates/incidents.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 3,
      "deletions": 3,
      "changes": 6
    },
    {
      "filename": "tests/test_incident_navigation.py",
      "previous_filename": "",
      "status": "added",
      "additions": 45,
      "deletions": 0,
      "changes": 45
    }
  ],
  "diff": "@@ -109,6 +109,8 @@ def static_asset_version() -> str:\\n         \"incKicker\": \"Incident Database\", \"incTitle\": \"事故資料庫\",\\n         \"incSub\": \"自動彙整全球民航事故與事件，資料來自各國調查機關與專業追報來源，每小時更新。\",\\n         \"incCountLabel\": \"筆紀錄\",\\n+        \"incWeeklySerious\": \"本週嚴重事件\",\\n+        \"incReadEvent\": \"閱讀事件\",\\n         \"incNote\": \"* 分級依 ICAO Annex 13：事故（Accident）、嚴重事件（Serious incident）、事件（Incident）。\",\\n         \"thDate\": \"日期\", \"thSev\": \"等級\", \"thType\": \"機型\", \"thOp\": \"營運人\",\\n         \"thPhase\": \"飛行階段\", \"thLoc\": \"地點\", \"thDesc\": \"摘要\", \"thStatus\": \"狀態\", \"thSrc\": \"來源\",\\n@@ -182,7 +184,7 @@ def static_asset_version() -> str:\\n                    \"🌐 國際航空產業動態\", \"🚄 地面與海運交通\"],\\n         \"brSevs\": {\"fatal\": \"致命\", \"serious\": \"嚴重\",\\n                    \"significant\": \"重要\", \"routine\": \"一般\"},\\n-        \"sevs\": [\"全部\", \"事故\", \"嚴重事件\", \"事件\"],\\n+        \"sevs\": [\"本週嚴重事件\", \"全部\", \"事故\", \"嚴重事件\", \"事件\"],\\n         \"themeOpts\": [\"亮\", \"暗\"],\\n         # production-only keys (not in the design dictionary)\\n         \"siteName\": \"SKYTICAL\",\\n@@ -224,6 +226,8 @@ def static_asset_version() -> str:\\n         \"incKicker\": \"Incident Database\", \"incTitle\": \"Incident database\",\\n         \"incSub\": \"Automatically aggregated civil-aviation accidents and incidents, from state investigators and specialist trackers. Updated hourly.\",\\n         \"incCountLabel\": \"records\",\\n+        \"incWeeklySerious\": \"Serious events this week\",\\n+        \"incReadEvent\": \"Read event\",\\n         \"incNote\": \"* Classes per ICAO Annex 13: Accident, Serious incident, Incident.\",\\n         \"thDate\": \"Date\", \"thSev\": \"Class\", \"thType\": \"Aircraft\", \"thOp\": \"Operator\",\\n         \"thPhase\": \"Phase\", \"thLoc\": \"Location\", \"thDesc\": \"Summary\", \"thStatus\": \"Status\", \"thSrc\": \"Sources\",\\n@@ -306,7 +310,7 @@ def static_asset_version() -> str:\\n                    \"🚄 Ground & maritime transport\"],\\n         \"brSevs\": {\"fatal\": \"Fatal\", \"serious\": \"Serious\",\\n                    \"significant\": \"Significant\", \"routine\": \"Routine\"},\\n-        \"sevs\": [\"All\", \"Accident\", \"Serious\", \"Incident\"],\\n+        \"sevs\": [\"This week\", \"All\", \"Accident\", \"Serious\", \"Incident\"],\\n         \"themeOpts\": [\"Light\", \"Dark\"],\\n         # production-only keys (not in the design dictionary)\\n         \"siteName\": \"SKYTICAL\",\\n@@ -1560,24 +1564,39 @@ def prep_incidents(raw):\\n     return [i for i in raw if isinstance(i, dict) and i.get(\"date\")]\\n \\n \\n-def inc_view(incidents, lang: str):\\n+def _is_weekly_serious_incident(incident, cutoff) -> bool:\\n+    if incident.get(\"sev\") not in (\"acc\", \"ser\"):\\n+        return False\\n+    try:\\n+        return parse_iso(str(incident.get(\"date\"))) >= cutoff\\n+    except (TypeError, ValueError):\\n+        return False\\n+\\n+\\n+def inc_view(incidents, lang: str, weekly_cutoff, published_ids):\\n     rows = []\\n     for i in incidents:\\n         date = str(i.get(\"date\") or \"\")\\n         disp = date[5:] if re.match(r\"\\d{4}-\\d{2}-\\d{2}\\Z\", date) else date\\n         sev = i.get(\"sev\") if isinstance(i.get(\"sev\"), str) else \"\"\\n         srcs = i.get(\"sources\")\\n         src = \" · \".join(str(s) for s in srcs) if isinstance(srcs, list) else str(srcs or \"\")\\n+        article_id = str(i.get(\"articleId\") or \"\")\\n+        article_url = None\\n+        if article_id in published_ids and _ID_RE.fullmatch(article_id):\\n+            article_url = page_url(lang, f\"news/{article_id}/\")\\n         rows.append({\\n             \"d\": disp,\\n             \"sev_key\": sev,\\n+            \"weekly_serious\": _is_weekly_serious_incident(i, weekly_cutoff),\\n             \"sev_label\": _bi(SEV.get(sev), lang, sev),\\n             \"sev_class\": SEV.get(sev, {}).get(\"cls\", \"tag-neutral\"),\\n             \"ac\": str(i.get(\"aircraft\") or \"—\"),\\n             \"op\": _bi(i.get(\"operator\"), lang, \"—\"),\\n             \"ph\": _bi(i.get(\"phase\"), lang, \"—\"),\\n             \"loc\": _bi(i.get(\"location\"), lang, \"—\"),\\n             \"desc\": _bi(i.get(\"desc\"), lang, \"\"),\\n+            \"url\": article_url,\\n             \"st\": _bi(ST.get(i.get(\"status\")), lang, str(i.get(\"status\") or \"\")),\\n             \"src\": src,\\n         })\\n@@ -2687,6 +2706,7 @@ def base_ctx(lang, page, sub, *, title, description, ticker, build,\\n             if BASE_PATH else \"/search-aliases.json\"\\n         ),\\n         \"radar_url\": page_url(lang, \"radar/\"),\\n+        \"incidents_week_url\": page_url(lang, \"incidents/\") + \"?filter=week\",\\n         \"about_url\": page_url(lang, \"about/\"),\\n         \"policy_url\": page_url(lang, \"editorial-policy/\"),\\n         \"changelog_url\": page_url(lang, \"changelog/\"),\\n@@ -2917,7 +2937,7 @@ def main() -> int:\\n         \"stamp\": f\"{now:%Y-%m-%d} {clock_12(now)}\",\\n     }\\n     cat_keys = [\"all\", \"safety\", \"reg\", \"biz\", \"ops\", \"mil\"]\\n-    sev_keys = [\"all\", \"acc\", \"ser\", \"inc\"]\\n+    sev_keys = [\"week\", \"all\", \"acc\", \"ser\", \"inc\"]\\n     pages = 0\\n     failed = 0\\n \\n@@ -3106,7 +3126,14 @@ def main() -> int:\\n                 failed += 1\\n                 print(f\"build: article {a['id']} ({lang}) failed: {exc}\")\\n \\n-        rows = inc_view(incidents, lang)\\n+        try:\\n+            weekly_cutoff = (\\n+                parse_iso(str(stats_raw.get(\"updatedUtc\")))\\n+                - timedelta(days=7)\\n+            )\\n+        except (TypeError, ValueError):\\n+            weekly_cutoff = now - timedelta(days=7)\\n+        rows = inc_view(incidents, lang, weekly_cutoff, published_ids)\\n         ctx = base_ctx(lang, \"incidents\", \"incidents/\",\\n                        title=f\"{t['siteName']} — {t['incTitle']}\",\\n                        description=t[\"incSub\"], ticker=ticker, build=build)\\n@@ -141,17 +141,33 @@\\n   /* — incidents: severity filter over rows ([data-sev]) + live record count — */\\n   var sevBtns = Array.prototype.slice.call(document.querySelectorAll(\"[data-filter-sev]\"));\\n   var countEl = document.getElementById(\"inc-count\");\\n+  function applyIncidentFilter(v, button, updateUrl) {\\n+    var n = 0;\\n+    sevBtns.forEach(function (x) { x.classList.toggle(\"active\", x === button); });\\n+    Array.prototype.forEach.call(document.querySelectorAll(\"[data-sev]\"), function (row) {\\n+      var matches = v === \"all\" ||\\n+        (v === \"week\" ? row.dataset.weeklySerious === \"true\" : row.dataset.sev === v);\\n+      row.classList.toggle(\"is-hidden\", !matches);\\n+      if (matches) n += 1;\\n+    });\\n+    if (countEl) countEl.textContent = String(n);\\n+    if (updateUrl && window.history && window.history.replaceState) {\\n+      var url = new URL(window.location.href);\\n+      if (v === \"all\") url.searchParams.delete(\"filter\");\\n+      else url.searchParams.set(\"filter\", v);\\n+      window.history.replaceState(null, \"\", url.pathname + url.search + url.hash);\\n+    }\\n+  }\\n   sevBtns.forEach(function (b) {\\n     b.addEventListener(\"click\", function () {\\n-      var v = b.dataset.filterSev;\\n-      var n = 0;\\n-      sevBtns.forEach(function (x) { x.classList.toggle(\"active\", x === b); });\\n-      Array.prototype.forEach.call(document.querySelectorAll(\"[data-sev]\"), function (row) {\\n-        var hide = v !== \"all\" && row.dataset.sev !== v;\\n-        row.classList.toggle(\"is-hidden\", hide);\\n-        if (!hide) n += 1;\\n-      });\\n-      if (countEl) countEl.textContent = String(n);\\n+      applyIncidentFilter(b.dataset.filterSev, b, true);\\n     });\\n   });\\n+  if (sevBtns.length) {\\n+    var requestedFilter = new URLSearchParams(window.location.search).get(\"filter\") || \"all\";\\n+    var initialButton = sevBtns.find(function (b) {\\n+      return b.dataset.filterSev === requestedFilter;\\n+    }) || sevBtns.find(function (b) { return b.dataset.filterSev === \"all\"; });\\n+    applyIncidentFilter(initialButton.dataset.filterSev, initialButton, false);\\n+  }\\n })();\\n@@ -337,6 +337,14 @@ a.flash-row:hover { color: inherit; }\\n   color: inherit;\\n   background: color-mix(in srgb, var(--color-accent) 7%, transparent);\\n }\\n+.incident-article-link,\\n+.incident-date-link {\\n+  color: inherit;\\n+  text-decoration-color: color-mix(in srgb, var(--color-accent) 55%, transparent);\\n+  text-underline-offset: 3px;\\n+}\\n+.incident-article-link:hover,\\n+.incident-date-link:hover { color: var(--color-accent); }\\n \\n /* — Taiwan civil-flight radar — */\\n .radar-page { padding: 24px 28px 32px; }\\n@@ -642,6 +650,16 @@ a.flash-row:hover { color: inherit; }\\n   .seg-cat .seg-btn:nth-child(n + 4) {\\n     border-top: 1px solid var(--color-divider);\\n   }\\n+  .seg-sev {\\n+    display: grid;\\n+    grid-template-columns: repeat(3, minmax(0, 1fr));\\n+    width: 100%;\\n+  }\\n+  .seg-sev .seg-btn { justify-content: center; }\\n+  .seg-sev .seg-btn:nth-child(4) { border-left: 0; }\\n+  .seg-sev .seg-btn:nth-child(n + 4) {\\n+    border-top: 1px solid var(--color-divider);\\n+  }\\n   .changelog-page { padding: 24px 14px 48px; }\\n   .changelog-summary { grid-template-columns: 1fr; }\\n   .changelog-summary > div + div {\\n@@ -101,10 +101,10 @@ <h6 style=\"margin:0 0 12px;\">{{ t.today }}</h6>\\n           <div style=\"font-size:24px;font-weight:800;letter-spacing:-.02em;\">{{ stats.delay }}</div>\\n           <div class=\"text-muted\" style=\"font-size:11px;\">{{ t.statDelay }}</div>\\n         </div>\\n-        <div style=\"padding:12px;border-right:1px solid var(--color-divider);\">\\n+        <a class=\"radar-stat-link\" href=\"{{ incidents_week_url }}\" aria-label=\"{{ t.incWeeklySerious }}：{{ stats.serious }} {{ t.incCountLabel }}\" style=\"padding:12px;border-right:1px solid var(--color-divider);\">\\n           <div style=\"font-size:24px;font-weight:800;letter-spacing:-.02em;\">{{ stats.serious }}</div>\\n           <div class=\"text-muted\" style=\"font-size:11px;\">{{ t.statSerious }}</div>\\n-        </div>\\n+        </a>\\n         <div style=\"padding:12px;\">\\n           <div style=\"font-size:24px;font-weight:800;letter-spacing:-.02em;\">{{ stats.notams }}</div>\\n           <div class=\"text-muted\" style=\"font-size:11px;\">{{ t.statNotam }}</div>\\n@@ -19,14 +19,14 @@ <h2 style=\"margin:8px 0 6px;\">{{ t.incTitle }}</h2>\\n         </tr></thead>\\n         <tbody>\\n           {% for i in rows %}\\n-          <tr data-sev=\"{{ i.sev_key }}\">\\n-            <td style=\"font-weight:700;white-space:nowrap;\">{{ i.d }}</td>\\n+          <tr data-sev=\"{{ i.sev_key }}\" data-weekly-serious=\"{{ 'true' if i.weekly_serious else 'false' }}\">\\n+            <td style=\"font-weight:700;white-space:nowrap;\">{% if i.url %}<a class=\"incident-date-link\" href=\"{{ i.url }}\" aria-label=\"{{ t.incReadEvent }}：{{ i.desc }}\">{{ i.d }}</a>{% else %}{{ i.d }}{% endif %}</td>\\n             <td><span class=\"tag {{ i.sev_class }}\">{{ i.sev_label }}</span></td>\\n             <td style=\"white-space:nowrap;\">{{ i.ac }}</td>\\n             <td>{{ i.op }}</td>\\n             <td>{{ i.ph }}</td>\\n             <td>{{ i.loc }}</td>\\n-            <td class=\"text-muted\">{{ i.desc }}</td>\\n+            <td class=\"text-muted\">{% if i.url %}<a class=\"incident-article-link\" href=\"{{ i.url }}\">{{ i.desc }}</a>{% else %}{{ i.desc }}{% endif %}</td>\\n             <td style=\"white-space:nowrap;\">{{ i.st }}</td>\\n             <td class=\"text-muted\" style=\"font-size:12px;white-space:nowrap;\">{{ i.src }}</td>\\n           </tr>\\n@@ -0,0 +1,45 @@\\n+\"\"\"Offline regression checks for weekly serious-incident navigation.\"\"\"\\n+from __future__ import annotations\\n+\\n+import json\\n+import re\\n+import sys\\n+from pathlib import Path\\n+\\n+ROOT = Path(__file__).resolve().parent.parent\\n+sys.path.insert(0, str(ROOT / \"pipeline\"))\\n+\\n+import build  # noqa: E402\\n+\\n+\\n+def main() -> None:\\n+    assert build.main() == 0\\n+\\n+    stats = json.loads(\\n+        (ROOT / \"data\" / \"stats.json\").read_text(encoding=\"utf-8\"))\\n+    expected = stats[\"seriousThisWeek\"]\\n+    home = (ROOT / \"site\" / \"index.html\").read_text(encoding=\"utf-8\")\\n+    en_home = (ROOT / \"site\" / \"en\" / \"index.html\").read_text(encoding=\"utf-8\")\\n+    incidents = (ROOT / \"site\" / \"incidents\" / \"index.html\").read_text(encoding=\"utf-8\")\\n+    script = (ROOT / \"static\" / \"app.js\").read_text(encoding=\"utf-8\")\\n+\\n+    assert 'href=\"/incidents/?filter=week\"' in home\\n+    assert 'href=\"/en/incidents/?filter=week\"' in en_home\\n+    assert 'data-filter-sev=\"week\"' in incidents\\n+    weekly_rows = re.findall(\\n+        r'<tr data-sev=\"(?:acc|ser)\" data-weekly-serious=\"true\">.*?</tr>',\\n+        incidents,\\n+        re.DOTALL,\\n+    )\\n+    assert len(weekly_rows) == expected\\n+    assert weekly_rows and all(\\n+        'class=\"incident-article-link\"' in row for row in weekly_rows)\\n+    assert all('class=\"incident-date-link\"' in row for row in weekly_rows)\\n+    assert 'new URLSearchParams(window.location.search)' in script\\n+    assert 'row.dataset.weeklySerious === \"true\"' in script\\n+\\n+    print(\"test_incident_navigation: OK\")\\n+\\n+\\n+if __name__ == \"__main__\":\\n+    main()"
}
