Full 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()